diff --git a/docs/refactor-v3/schema-map.md b/docs/refactor-v3/schema-map.md index ef0d44a..de9c569 100644 --- a/docs/refactor-v3/schema-map.md +++ b/docs/refactor-v3/schema-map.md @@ -19,7 +19,7 @@ | Asset | `asset_bucket`, `asset_object`, `asset_reference`, `asset_access_grant` | Batch 3 | MinIO object ownership and access grant. | | System Event | `admin_notice`, `system_notice`, `system_event`, `system_event_dedupe`, `system_event_delivery` | Batch 2 | Admin notices and MySQL actionable events; Loki remains log query source. | | Runtime Evidence | `runtime_evidence_index` | Batch 1 | Safe index only, no large logs or secrets. | -| QQBot Core | `qqbot_account`, `qqbot_account_ability`, `qqbot_connection_session`, `qqbot_capability_binding`, `qqbot_permission_policy`, `qqbot_allowlist`, `qqbot_blocklist`, `qqbot_command`, `qqbot_command_alias`, `qqbot_command_log`, `qqbot_config`, `qqbot_rule`, `qqbot_conversation`, `qqbot_message`, `qqbot_send_task`, `qqbot_send_log`, `qqbot_dedupe_event`, `qqbot_dedupe`, `qqbot_account_napcat`, `qqbot_napcat_container` | Batch 4 | Account, connection, permission, command, message, send queue, and transitional registered-entity compatibility tables until Batches 5-7 replace plugin/NapCat internals. | -| NapCat Runtime | `napcat_container`, `napcat_device_identity`, `napcat_account_binding`, `napcat_login_session`, `napcat_login_challenge`, `napcat_runtime_cleanup` | Batch 7 | Device identity and login challenge state. | +| QQBot Core | `qqbot_account`, `qqbot_account_ability`, `qqbot_connection_session`, `qqbot_capability_binding`, `qqbot_permission_policy`, `qqbot_allowlist`, `qqbot_blocklist`, `qqbot_command`, `qqbot_command_alias`, `qqbot_command_log`, `qqbot_config`, `qqbot_rule`, `qqbot_conversation`, `qqbot_message`, `qqbot_send_task`, `qqbot_send_log`, `qqbot_dedupe_event`, `qqbot_dedupe` | Batch 4 | Account, connection, permission, command, message, send queue, and dedupe state. | +| NapCat Runtime | `napcat_container`, `napcat_device_identity`, `napcat_account_binding`, `napcat_login_session`, `napcat_login_challenge`, `napcat_runtime_cleanup` | Batch 7 | Container runtime, account binding, device identity, login challenge, and cleanup state. | | QQBot Plugin Platform | `qqbot_plugin`, `qqbot_plugin_version`, `qqbot_plugin_installation`, `qqbot_plugin_operation`, `qqbot_plugin_event_handler`, `qqbot_plugin_account_binding`, `qqbot_plugin_config`, `qqbot_plugin_asset`, `qqbot_plugin_runtime_event` | Batch 5 | Manifest, install lifecycle, runtime health, bindings. | | Plugin-Owned Data | plugin namespace tables | Batch 6 | Table names start with registered plugin namespace. | diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index b6ad2ac..76b5fbb 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -40,9 +40,10 @@ CREATE TABLE IF NOT EXISTS `qqbot_account_ability` ( KEY `idx_qqbot_account_ability_self` (`self_id`, `ability_type`, `is_deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS `qqbot_napcat_container` ( +CREATE TABLE IF NOT EXISTS `napcat_container` ( `id` bigint NOT NULL, - `name` varchar(120) NOT NULL, + `account_id` bigint DEFAULT NULL, + `container_name` varchar(120) NOT NULL, `base_url` varchar(255) NOT NULL, `webui_port` int DEFAULT NULL, `webui_token` varchar(255) DEFAULT NULL, @@ -58,15 +59,34 @@ CREATE TABLE IF NOT EXISTS `qqbot_napcat_container` ( `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), - UNIQUE KEY `uk_qqbot_napcat_container_name` (`name`), - KEY `idx_qqbot_napcat_container_status` (`status`, `is_deleted`) + UNIQUE KEY `uk_napcat_container_name` (`container_name`), + KEY `idx_napcat_container_status` (`status`, `is_deleted`), + KEY `idx_napcat_container_account` (`account_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS `qqbot_account_napcat` ( +CREATE TABLE IF NOT EXISTS `napcat_device_identity` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `container_id` bigint DEFAULT NULL, + `data_dir` varchar(512) NOT NULL, + `hostname` varchar(128) NOT NULL, + `machine_id_path` varchar(512) NOT NULL, + `mac_address` varchar(64) NOT NULL, + `verification_status` varchar(32) NOT NULL, + `last_login_evidence` json DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_napcat_device_identity_account` (`account_id`), + KEY `idx_napcat_device_identity_container` (`container_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_account_binding` ( `id` bigint NOT NULL, `account_id` bigint NOT NULL, `container_id` bigint NOT NULL, - `bind_status` varchar(32) NOT NULL DEFAULT 'pending', + `device_identity_id` bigint DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', `is_primary` tinyint(1) NOT NULL DEFAULT 1, `last_login_at` datetime DEFAULT NULL, `remark` varchar(255) NOT NULL DEFAULT '', @@ -74,8 +94,51 @@ CREATE TABLE IF NOT EXISTS `qqbot_account_napcat` ( `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), - KEY `idx_qqbot_account_napcat_account` (`account_id`, `is_deleted`), - KEY `idx_qqbot_account_napcat_container` (`container_id`, `is_deleted`) + UNIQUE KEY `uk_napcat_account_binding_account` (`account_id`), + KEY `idx_napcat_account_binding_container` (`container_id`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_login_session` ( + `id` bigint NOT NULL, + `account_id` bigint DEFAULT NULL, + `session_key` varchar(128) NOT NULL, + `login_stage` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL, + `progress_message` varchar(255) NOT NULL, + `session_payload` json DEFAULT NULL, + `expires_at` datetime DEFAULT NULL, + `completed_at` datetime DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_napcat_login_session_key` (`session_key`), + KEY `idx_napcat_login_session_account` (`account_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_login_challenge` ( + `id` bigint NOT NULL, + `session_id` bigint NOT NULL, + `challenge_type` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL, + `challenge_url` text, + `challenge_payload` json DEFAULT NULL, + `resolved_at` datetime DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_napcat_login_challenge_session` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_runtime_cleanup` ( + `id` bigint NOT NULL, + `session_id` bigint NOT NULL, + `cleanup_type` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL, + `error_message` text, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_napcat_runtime_cleanup_session` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `qqbot_config` ( @@ -366,141 +429,6 @@ PREPARE qqbot_stmt FROM @qqbot_sql; EXECUTE qqbot_stmt; DEALLOCATE PREPARE qqbot_stmt; -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `webui_port` int DEFAULT NULL', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'webui_port' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `webui_token` varchar(255) DEFAULT NULL', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'webui_token' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `image` varchar(255) NOT NULL DEFAULT ''''', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'image' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `data_dir` varchar(500) NOT NULL DEFAULT ''''', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'data_dir' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `reverse_ws_url` varchar(500) NOT NULL DEFAULT ''''', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'reverse_ws_url' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `status` varchar(32) NOT NULL DEFAULT ''creating''', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'status' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_started_at` datetime DEFAULT NULL', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'last_started_at' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_checked_at` datetime DEFAULT NULL', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'last_checked_at' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - -SET @qqbot_sql = ( - SELECT IF( - COUNT(*) = 0, - 'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_error` varchar(500) DEFAULT NULL', - 'SELECT 1' - ) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'qqbot_napcat_container' - AND column_name = 'last_error' -); -PREPARE qqbot_stmt FROM @qqbot_sql; -EXECUTE qqbot_stmt; -DEALLOCATE PREPARE qqbot_stmt; - SET @qqbot_sql = ( SELECT IF( COUNT(*) > 0, diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 9ba53ea..8e341cf 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -877,33 +877,8 @@ CREATE TABLE IF NOT EXISTS qqbot_plugin_runtime_event ( CREATE TABLE IF NOT EXISTS napcat_container ( id BIGINT NOT NULL PRIMARY KEY, - account_id BIGINT NOT NULL, - container_name VARCHAR(128) NOT NULL, - image_name VARCHAR(255) NOT NULL, - status VARCHAR(32) NOT NULL, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_napcat_container_name (container_name) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS qqbot_account_napcat ( - id BIGINT NOT NULL PRIMARY KEY, - account_id BIGINT NOT NULL, - container_id BIGINT NOT NULL, - bind_status VARCHAR(32) NOT NULL DEFAULT 'pending', - is_primary TINYINT NOT NULL DEFAULT 1, - last_login_at DATETIME NULL, - remark VARCHAR(255) NOT NULL DEFAULT '', - is_deleted TINYINT NOT NULL DEFAULT 0, - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - KEY idx_qqbot_account_napcat_account (account_id, is_deleted), - KEY idx_qqbot_account_napcat_container (container_id, is_deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS qqbot_napcat_container ( - id BIGINT NOT NULL PRIMARY KEY, - name VARCHAR(120) NOT NULL, + account_id BIGINT NULL, + container_name VARCHAR(120) NOT NULL, base_url VARCHAR(255) NOT NULL, webui_port INT NULL, webui_token VARCHAR(255) NULL, @@ -918,8 +893,9 @@ CREATE TABLE IF NOT EXISTS qqbot_napcat_container ( is_deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_qqbot_napcat_container_name (name), - KEY idx_qqbot_napcat_container_status (status, is_deleted) + UNIQUE KEY uk_napcat_container_name (container_name), + KEY idx_napcat_container_status (status, is_deleted), + KEY idx_napcat_container_account (account_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS napcat_device_identity ( @@ -942,11 +918,16 @@ CREATE TABLE IF NOT EXISTS napcat_account_binding ( id BIGINT NOT NULL PRIMARY KEY, account_id BIGINT NOT NULL, container_id BIGINT NOT NULL, - device_identity_id BIGINT NOT NULL, - status VARCHAR(32) NOT NULL, + device_identity_id BIGINT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + is_primary TINYINT NOT NULL DEFAULT 1, + last_login_at DATETIME NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + is_deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_napcat_account_binding_account (account_id) + UNIQUE KEY uk_napcat_account_binding_account (account_id), + KEY idx_napcat_account_binding_container (container_id, is_deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS napcat_login_session ( diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index abdcf83..ca407ad 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -117,45 +117,36 @@ INSERT INTO admin_menu ( 1, 110 ), - ( - 2041700000000100402, - 2041700000000100400, - 'QqBotAccount', - '/qqbot/account', - '/qqbot/account/list', - NULL, - 'QqBot:Account:List', - 'menu', - '{"icon":"lucide:radio-receiver","title":"账号连接"}', - 1, - 1 - ), - ( - 2041700000000100408, - 2041700000000100400, - 'QqBotCommand', - '/qqbot/command', - '/qqbot/command/list', - NULL, - 'QqBot:Command:List', - 'menu', - '{"icon":"lucide:square-terminal","title":"在线命令"}', - 1, - 3 - ), - ( - 2041700000000100409, - 2041700000000100400, - 'QqBotPlugin', - '/qqbot/plugin', - '/qqbot/plugin/list', - NULL, - 'QqBot:Plugin:List', - 'menu', - '{"icon":"lucide:plug","title":"插件能力"}', - 1, - 4 - ) + (2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0), + (2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1), + (2041700000000100410, 2041700000000100400, 'QqBotAccountConfig', '/qqbot/account/config', '/qqbot/account/config', NULL, 'QqBot:Account:Config', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"账号功能配置"}', 1, 0), + (2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0), + (2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0), + (2041700000000120406, 2041700000000100402, 'QqBotAccountConfigButton', NULL, NULL, NULL, 'QqBot:Account:Config', 'button', '{"title":"配置"}', 1, 0), + (2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2), + (2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120413, 2041700000000100403, 'QqBotRuleDelete', NULL, NULL, NULL, 'QqBot:Rule:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120414, 2041700000000100403, 'QqBotRuleToggle', NULL, NULL, NULL, 'QqBot:Rule:Toggle', 'button', '{"title":"启停"}', 1, 0), + (2041700000000100408, 2041700000000100400, 'QqBotCommand', '/qqbot/command', '/qqbot/command/list', NULL, 'QqBot:Command:List', 'menu', '{"icon":"lucide:square-terminal","title":"在线命令"}', 1, 3), + (2041700000000120441, 2041700000000100408, 'QqBotCommandCreate', NULL, NULL, NULL, 'QqBot:Command:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120442, 2041700000000100408, 'QqBotCommandEdit', NULL, NULL, NULL, 'QqBot:Command:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120443, 2041700000000100408, 'QqBotCommandDelete', NULL, NULL, NULL, 'QqBot:Command:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120444, 2041700000000100408, 'QqBotCommandToggle', NULL, NULL, NULL, 'QqBot:Command:Toggle', 'button', '{"title":"启停"}', 1, 0), + (2041700000000120445, 2041700000000100408, 'QqBotCommandTest', NULL, NULL, NULL, 'QqBot:Command:Test', 'button', '{"title":"测试命令"}', 1, 0), + (2041700000000100409, 2041700000000100400, 'QqBotPlugin', '/qqbot/plugin', '/qqbot/plugin/list', NULL, 'QqBot:Plugin:List', 'menu', '{"icon":"lucide:plug","title":"插件能力"}', 1, 4), + (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 5), + (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 6), + (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 7), + (2041700000000120421, 2041700000000100406, 'QqBotSendPrivate', NULL, NULL, NULL, 'QqBot:Send:Private', 'button', '{"title":"发送私聊"}', 1, 0), + (2041700000000120422, 2041700000000100406, 'QqBotSendGroup', NULL, NULL, NULL, 'QqBot:Send:Group', 'button', '{"title":"发送群聊"}', 1, 0), + (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 8), + (2041700000000120431, 2041700000000100407, 'QqBotPermissionCreate', NULL, NULL, NULL, 'QqBot:Permission:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120432, 2041700000000100407, 'QqBotPermissionEdit', NULL, NULL, NULL, 'QqBot:Permission:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0) ON DUPLICATE KEY UPDATE pid = VALUES(pid), path = VALUES(path), @@ -280,8 +271,8 @@ INSERT INTO qqbot_command ( 'bangdream.song.search', 'bangdream_song', 'bd', - 'BangDream 查歌', - '["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]', + '', + '[]', 'bangdream', 1, 2 @@ -291,8 +282,8 @@ INSERT INTO qqbot_command ( 'bangdream.song.chart', 'bangdream_song_chart', 'bangdream_song_chart', - 'BangDream 查谱面', - '["查谱面","谱面","bd谱面"]', + '', + '[]', 'bangdream', 1, 2 @@ -302,8 +293,8 @@ INSERT INTO qqbot_command ( 'bangdream.song.random', 'bangdream_song_random', 'bangdream_song_random', - 'BangDream 随机曲', - '["随机曲","随机","bd随机"]', + '', + '[]', 'bangdream', 1, 2 @@ -313,8 +304,8 @@ INSERT INTO qqbot_command ( 'bangdream.song.meta', 'bangdream_song_meta', 'bangdream_song_meta', - 'BangDream 分数表', - '["查询分数表","查分数表","查询分数榜","查分数榜","bd分数表"]', + '', + '[]', 'bangdream', 1, 2 @@ -324,8 +315,8 @@ INSERT INTO qqbot_command ( 'bangdream.card.search', 'bangdream_card', 'bangdream_card', - 'BangDream 查卡', - '["查卡","查卡牌","bd查卡"]', + '', + '[]', 'bangdream', 1, 2 @@ -335,8 +326,8 @@ INSERT INTO qqbot_command ( 'bangdream.card.illustration', 'bangdream_card_illustration', 'bangdream_card_illustration', - 'BangDream 查卡面', - '["查卡面","查卡插画","查插画","bd卡面"]', + '', + '[]', 'bangdream', 1, 2 @@ -346,8 +337,8 @@ INSERT INTO qqbot_command ( 'bangdream.character.search', 'bangdream_character', 'bangdream_character', - 'BangDream 查角色', - '["查角色","bd角色"]', + '', + '[]', 'bangdream', 1, 2 @@ -357,8 +348,8 @@ INSERT INTO qqbot_command ( 'bangdream.event.search', 'bangdream_event', 'bangdream_event', - 'BangDream 查活动', - '["查活动","bd活动"]', + '', + '[]', 'bangdream', 1, 2 @@ -368,8 +359,8 @@ INSERT INTO qqbot_command ( 'bangdream.event.stage', 'bangdream_event_stage', 'bangdream_event_stage', - 'BangDream 查试炼', - '["查试炼","查stage","查舞台","查festival","查5v5"]', + '', + '[]', 'bangdream', 1, 2 @@ -379,8 +370,8 @@ INSERT INTO qqbot_command ( 'bangdream.player.search', 'bangdream_player', 'bangdream_player', - 'BangDream 查玩家', - '["查玩家","查询玩家","bd玩家"]', + '', + '[]', 'bangdream', 1, 2 @@ -390,8 +381,8 @@ INSERT INTO qqbot_command ( 'bangdream.gacha.search', 'bangdream_gacha', 'bangdream_gacha', - 'BangDream 查卡池', - '["查卡池","bd卡池"]', + '', + '[]', 'bangdream', 1, 2 @@ -401,8 +392,8 @@ INSERT INTO qqbot_command ( 'bangdream.gacha.simulate', 'bangdream_gacha_simulate', 'bangdream_gacha_simulate', - 'BangDream 抽卡模拟', - '["抽卡模拟","bd抽卡"]', + '', + '[]', 'bangdream', 1, 3 @@ -412,8 +403,8 @@ INSERT INTO qqbot_command ( 'bangdream.cutoff.detail', 'bangdream_cutoff_detail', 'bangdream_cutoff_detail', - 'BangDream ycx', - '["ycx","预测线","查档线","bd档线"]', + '', + '[]', 'bangdream', 1, 3 @@ -423,8 +414,8 @@ INSERT INTO qqbot_command ( 'bangdream.cutoff.all', 'bangdream_cutoff_all', 'bangdream_cutoff_all', - 'BangDream ycxall', - '["ycxall","myycx","全部档线"]', + '', + '[]', 'bangdream', 1, 3 @@ -434,8 +425,8 @@ INSERT INTO qqbot_command ( 'bangdream.cutoff.recent', 'bangdream_cutoff_recent', 'bangdream_cutoff_recent', - 'BangDream lsycx', - '["lsycx","历史档线","近期档线"]', + '', + '[]', 'bangdream', 1, 3 diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index f75882e..18be121 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -79,6 +79,18 @@ WHERE table_schema = DATABASE() AND table_name = 'napcat_account_binding' AND index_name = 'uk_napcat_account_binding_account'; +SELECT 'index_napcat_account_binding_container' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_account_binding' + AND index_name = 'idx_napcat_account_binding_container'; + +SELECT 'index_napcat_container_name' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_container' + AND index_name = 'uk_napcat_container_name'; + SELECT 'index_napcat_login_session_key' AS check_name, COUNT(*) AS matched_rows FROM information_schema.statistics WHERE table_schema = DATABASE() diff --git a/src/app.module.ts b/src/app.module.ts index 2442a8e..334e68a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AdminModule } from './modules/admin/admin.module'; import { AssetModule } from './modules/asset/asset.module'; import { BlogContentModule } from './modules/blog/blog-content.module'; import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.module'; +import { QqbotNapcatModule } from './modules/qqbot/napcat/qqbot-napcat.module'; import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module'; import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module'; import { RuntimeModule } from './runtime'; @@ -74,6 +75,7 @@ import { RuntimeModule } from './runtime'; WordpressMirrorModule, AssetModule, QqbotCoreModule, + QqbotNapcatModule, QqbotPluginPlatformModule, ], providers: [ diff --git a/src/modules/qqbot/core/application/account/qqbot-account.service.ts b/src/modules/qqbot/core/application/account/qqbot-account.service.ts index 4b170af..9b29fa4 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account.service.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account.service.ts @@ -16,8 +16,8 @@ import type { QqbotAccountQueryDto, QqbotAccountUpdateDto, } from '../../contract/account/qqbot-account.dto'; -import { QqbotAccountNapcat } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity'; -import { QqbotNapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity'; +import { NapcatAccountBinding } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity'; +import { NapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity'; import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { QQBOT_DEFAULT_PAGE_NO, @@ -45,10 +45,10 @@ export class QqbotAccountService { private readonly accountRepository: Repository, @InjectRepository(QqbotAccountAbility) private readonly accountAbilityRepository: Repository, - @InjectRepository(QqbotAccountNapcat) - private readonly accountNapcatRepository: Repository, - @InjectRepository(QqbotNapcatContainer) - private readonly napcatContainerRepository: Repository, + @InjectRepository(NapcatAccountBinding) + private readonly accountNapcatRepository: Repository, + @InjectRepository(NapcatContainer) + private readonly napcatContainerRepository: Repository, private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, @Optional() @@ -437,7 +437,7 @@ export class QqbotAccountService { .orderBy('binding.isPrimary', 'DESC') .addOrderBy('binding.updateTime', 'DESC') .getMany(); - const bindingMap = new Map(); + const bindingMap = new Map(); for (const binding of bindings) { if (!bindingMap.has(binding.accountId)) { bindingMap.set(binding.accountId, binding); @@ -447,7 +447,7 @@ export class QqbotAccountService { const containerIds = Array.from( new Set(bindings.map((binding) => binding.containerId).filter(Boolean)), ); - const containerMap = new Map(); + const containerMap = new Map(); if (containerIds.length > 0) { const containerBuilder = this.napcatContainerRepository .createQueryBuilder('container'); @@ -500,7 +500,7 @@ export class QqbotAccountService { private async syncNapcatRuntimeState( account: QqbotAccount, - container?: QqbotNapcatContainer, + container?: NapcatContainer, options: { autoLogin?: boolean } = {}, ) { const runtimeStatus = await this.getNapcatRuntimeStatus(account, container); @@ -561,7 +561,7 @@ export class QqbotAccountService { private async getNapcatRuntimeStatus( account: QqbotAccount, - container?: QqbotNapcatContainer, + container?: NapcatContainer, ): Promise { if (!container) return undefined; const cached = this.toCachedNapcatRuntimeStatus(container); @@ -600,7 +600,7 @@ export class QqbotAccountService { } private toCachedNapcatRuntimeStatus( - container: QqbotNapcatContainer, + container: NapcatContainer, ): QqbotNapcatRuntimeStatusSnapshot { const containerOnline = container.status === 'running'; const lastError = this.toolsService.toTrimmedString(container.lastError); @@ -663,7 +663,7 @@ export class QqbotAccountService { private async tryAutoLogin( account: QqbotAccount, - container: QqbotNapcatContainer, + container: NapcatContainer, ) { try { const result = await this.napcatContainerService.tryAutoLogin(container, { @@ -693,7 +693,7 @@ export class QqbotAccountService { private async applyNapcatOfflineState( account: QqbotAccount, - container: QqbotNapcatContainer, + container: NapcatContainer, offlineReason: string, ) { await this.markQqLoginOffline(account.selfId, offlineReason); @@ -704,7 +704,7 @@ export class QqbotAccountService { }); } - private getFreshCachedOfflineReason(container: QqbotNapcatContainer) { + private getFreshCachedOfflineReason(container: NapcatContainer) { if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; const reason = this.toolsService.toTrimmedString(container.lastError); return this.toolsService.isNapcatOfflineLoginMessage(reason) @@ -714,7 +714,7 @@ export class QqbotAccountService { private isRecentConnectNewerThanRuntimeCheck( account: QqbotAccount, - container: QqbotNapcatContainer, + container: NapcatContainer, ) { const checkedAt = this.toTime(container.lastCheckedAt); if (!checkedAt) return false; diff --git a/src/modules/qqbot/core/application/command/qqbot-command-parser.service.ts b/src/modules/qqbot/core/application/command/qqbot-command-parser.service.ts index 834ae41..311145a 100644 --- a/src/modules/qqbot/core/application/command/qqbot-command-parser.service.ts +++ b/src/modules/qqbot/core/application/command/qqbot-command-parser.service.ts @@ -1,15 +1,25 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import type { QqbotCommandMatchResult } from '../../contract/qqbot.types'; import type { QqbotNormalizedMessage } from '../../contract/qqbot.types'; +import { + QQBOT_PLUGIN_EXECUTION_PORT, + type QqbotPluginExecutionPort, +} from '../../domain/plugin-execution.port'; import type { QqbotCommand } from '../../infrastructure/persistence/command/qqbot-command.entity'; @Injectable() export class QqbotCommandParserService { + constructor( + @Optional() + @Inject(QQBOT_PLUGIN_EXECUTION_PORT) + private readonly pluginExecution?: QqbotPluginExecutionPort, + ) {} + async match(command: QqbotCommand, message: QqbotNormalizedMessage) { const source = `${message.messageText || ''}`.trim(); if (!source) return null; - const aliases = this.getAliases(command); + const aliases = await this.getAliases(command); const prefixes = this.getPrefixes(command); for (const alias of aliases) { for (const prefix of prefixes) { @@ -27,8 +37,11 @@ export class QqbotCommandParserService { return null; } - getAliases(command: QqbotCommand) { - return this.normalizeList(command.aliases, [command.code, command.name]); + async getAliases(command: QqbotCommand) { + return this.mergeLists( + await this.getManifestAliases(command), + this.normalizeList(command.aliases, [command.code, command.name]), + ); } getPrefixes(command: QqbotCommand) { @@ -63,6 +76,29 @@ export class QqbotCommandParserService { return [...new Set(list)]; } + private async getManifestAliases(command: QqbotCommand) { + if (!this.pluginExecution) return []; + try { + const operation = await this.pluginExecution.getOperationByCommand({ + operationKey: command.operationKey, + pluginKey: command.pluginKey, + }); + return this.normalizeArray(operation?.aliases || []); + } catch { + return []; + } + } + + private mergeLists(...sources: string[][]) { + return [ + ...new Set(sources.flat().map((item) => item.trim()).filter(Boolean)), + ]; + } + + private normalizeArray(value: unknown[]) { + return value.map((item) => `${item || ''}`).filter(Boolean); + } + private tryParseJsonArray(value: string) { if (!value.startsWith('[')) return []; try { diff --git a/src/modules/qqbot/core/contract/account/qqbot-account.controller.ts b/src/modules/qqbot/core/contract/account/qqbot-account.controller.ts index ad29527..9a357b1 100644 --- a/src/modules/qqbot/core/contract/account/qqbot-account.controller.ts +++ b/src/modules/qqbot/core/contract/account/qqbot-account.controller.ts @@ -6,7 +6,6 @@ import { HttpStatus, Post, Query, - Sse, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; @@ -15,12 +14,9 @@ import { vbenSuccess } from '@/common'; import { QqbotAccountBodyDto, QqbotAccountQueryDto, - QqbotAccountScanCaptchaDto, - QqbotAccountScanStatusDto, QqbotAccountUpdateDto, } from './qqbot-account.dto'; import { QqbotAccountService } from '../../application/account/qqbot-account.service'; -import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.service'; import { QqbotReverseWsService } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service'; @ApiTags('QQBot - 账号连接') @@ -29,7 +25,6 @@ import { QqbotReverseWsService } from '../../infrastructure/integration/connecti export class QqbotAccountController { constructor( private readonly accountService: QqbotAccountService, - private readonly napcatLoginService: QqbotNapcatLoginService, private readonly reverseWsService: QqbotReverseWsService, ) {} @@ -59,58 +54,6 @@ export class QqbotAccountController { return vbenSuccess(await this.accountService.update(body)); } - @Post('scan/create') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '扫码新增 QQBot 账号' }) - async scanCreate() { - return vbenSuccess(await this.napcatLoginService.startCreate()); - } - - @Post('scan/refresh') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '扫码刷新 QQBot 账号登录态' }) - @ApiQuery({ name: 'id', type: String }) - async scanRefresh(@Query('id') id: string) { - return vbenSuccess(await this.napcatLoginService.startRefresh(id)); - } - - @Get('scan/status') - @ApiOperation({ summary: '查询 QQBot 扫码登录状态' }) - async scanStatus(@Query() query: QqbotAccountScanStatusDto) { - return vbenSuccess(await this.napcatLoginService.status(query.sessionId)); - } - - @Sse('scan/events') - @ApiOperation({ summary: '订阅 QQBot 扫码登录进度' }) - scanEvents(@Query() query: QqbotAccountScanStatusDto) { - return this.napcatLoginService.events(query.sessionId); - } - - @Post('scan/qrcode/refresh') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '刷新 QQBot 扫码二维码' }) - async refreshScanQrcode(@Query() query: QqbotAccountScanStatusDto) { - return vbenSuccess( - await this.napcatLoginService.refreshQrcode(query.sessionId), - ); - } - - @Post('scan/captcha/submit') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '提交 QQBot 登录安全验证码' }) - async submitScanCaptcha(@Body() body: QqbotAccountScanCaptchaDto) { - return vbenSuccess( - await this.napcatLoginService.submitCaptcha(body.sessionId, body), - ); - } - - @Post('scan/cancel') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '取消 QQBot 扫码登录会话' }) - async cancelScan(@Query() query: QqbotAccountScanStatusDto) { - return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId)); - } - @Post('delete') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '删除 QQBot 账号' }) diff --git a/src/modules/qqbot/core/contract/account/qqbot-account.dto.ts b/src/modules/qqbot/core/contract/account/qqbot-account.dto.ts index bbcb8e9..7195659 100644 --- a/src/modules/qqbot/core/contract/account/qqbot-account.dto.ts +++ b/src/modules/qqbot/core/contract/account/qqbot-account.dto.ts @@ -45,19 +45,3 @@ export class QqbotAccountQueryDto { @ApiPropertyOptional() connectStatus?: string; } - -export class QqbotAccountScanStatusDto { - @ApiProperty() - sessionId: string; -} - -export class QqbotAccountScanCaptchaDto extends QqbotAccountScanStatusDto { - @ApiProperty() - randstr: string; - - @ApiPropertyOptional() - sid?: string; - - @ApiProperty() - ticket: string; -} diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 2fc01bb..48be41f 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -152,12 +152,14 @@ export type QqbotPluginOperationContext = { }; export type QqbotPluginOperation = { + aliases?: string[]; cacheTtlMs?: number; description?: string; inputSchema?: Record; key: string; name: string; outputSchema?: Record; + timeoutMs?: number; execute: ( input: Input, context: QqbotPluginOperationContext, @@ -206,6 +208,7 @@ export type QqbotPluginSummary = { }; export type QqbotPluginOperationSummary = { + aliases?: string[]; cacheTtlMs?: number; description?: string; inputSchema?: Record; @@ -213,6 +216,7 @@ export type QqbotPluginOperationSummary = { name: string; outputSchema?: Record; pluginKey: string; + timeoutMs?: number; triggerMode: QqbotPluginTriggerMode; }; diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index d2f04c8..a8eda29 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -3,13 +3,12 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module'; import { DictModule } from '@/modules/admin/platform-config/dict/dict.module'; +import { QqbotNapcatModule } from '@/modules/qqbot/napcat/qqbot-napcat.module'; import { QqbotPluginPlatformModule } from '@/modules/qqbot/plugin-platform/plugin-platform.module'; import { QqbotAccountAbility } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account-ability.entity'; import { QqbotAccountController } from '@/modules/qqbot/core/contract/account/qqbot-account.controller'; import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; -import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.service'; -import { QqbotNapcatWatchdogService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-watchdog.service'; import { QqbotCommandController } from '@/modules/qqbot/core/contract/command/qqbot-command.controller'; import { QqbotCommand } from '@/modules/qqbot/core/infrastructure/persistence/command/qqbot-command.entity'; import { QqbotCommandEngineService } from '@/modules/qqbot/core/application/command/qqbot-command-engine.service'; @@ -25,22 +24,11 @@ import { QqbotDashboardService } from '@/modules/qqbot/core/application/dashboar import { QqbotDedupe } from '@/modules/qqbot/core/infrastructure/persistence/dedupe/qqbot-dedupe.entity'; import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service'; import { QqbotEventService } from '@/modules/qqbot/core/application/event/qqbot-event.service'; -import { NapcatAccountBinding } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity'; -import { NapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity'; -import { NapcatDeviceIdentity } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity'; -import { NapcatDeviceIdentityService } from '@/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service'; -import { NapcatLoginChallengeEntity } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-challenge.entity'; -import { NapcatLoginSession } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-session.entity'; -import { NapcatLoginStateStoreService } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service'; -import { NapcatRuntimeCleanup } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-cleanup.entity'; import { QqbotConversation } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-conversation.entity'; import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller'; import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity'; import { QqbotMessageService } from '@/modules/qqbot/core/application/message/qqbot-message.service'; import { QqbotBusService } from '@/modules/qqbot/core/infrastructure/integration/bus/qqbot-bus.service'; -import { QqbotAccountNapcat } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity'; -import { QqbotNapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity'; -import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; import { QqbotAllowlist } from '@/modules/qqbot/core/infrastructure/persistence/permission/qqbot-allowlist.entity'; import { QqbotBlocklist } from '@/modules/qqbot/core/infrastructure/persistence/permission/qqbot-blocklist.entity'; import { QqbotPermissionController } from '@/modules/qqbot/core/contract/permission/qqbot-permission.controller'; @@ -67,14 +55,6 @@ export const QQBOT_CORE_ENTITIES = [ QqbotConversation, QqbotDedupe, QqbotMessage, - NapcatAccountBinding, - NapcatContainer, - NapcatDeviceIdentity, - NapcatLoginChallengeEntity, - NapcatLoginSession, - NapcatRuntimeCleanup, - QqbotAccountNapcat, - QqbotNapcatContainer, QqbotRule, QqbotSendLog, ]; @@ -100,11 +80,6 @@ export const QQBOT_CORE_PROVIDERS = [ QqbotDedupeService, QqbotEventService, QqbotMessageService, - NapcatDeviceIdentityService, - NapcatLoginStateStoreService, - QqbotNapcatLoginService, - QqbotNapcatWatchdogService, - QqbotNapcatContainerService, QqbotPermissionService, QqbotRateLimitService, QqbotReplyTemplateService, @@ -117,9 +92,6 @@ export const QQBOT_CORE_PROVIDERS = [ export const QQBOT_CORE_EXPORTS = [ QqbotAccountService, QqbotSendService, - NapcatDeviceIdentityService, - QqbotNapcatLoginService, - QqbotNapcatContainerService, QqbotReverseWsService, ]; @@ -128,6 +100,7 @@ export const QQBOT_CORE_EXPORTS = [ ConfigModule, AdminAuthGuardModule, DictModule, + forwardRef(() => QqbotNapcatModule), forwardRef(() => QqbotPluginPlatformModule), TypeOrmModule.forFeature(QQBOT_CORE_ENTITIES), ], diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index 63124a8..ffbac5c 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -1,15 +1,14 @@ -import * as http from 'http'; -import * as https from 'https'; import { createHash, randomUUID } from 'crypto'; import { Injectable, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable } from 'rxjs'; import { throwVbenError, ToolsService } from '@/common'; -import { NapcatLoginApiClient } from '../../infrastructure/integration/napcat-login-api.client'; +import { + NapcatLoginApiClient, + NapcatWebuiHttpClient, +} from '../../infrastructure/integration/napcat-login-api.client'; import type { - NapcatApiResponse, NapcatCaptchaLoginResult, - NapcatCredential, NapcatLoginInfo, NapcatLoginStatus, NapcatQrcode, @@ -60,10 +59,9 @@ export class QqbotNapcatLoginService { delete this.sessionEventListenerCache[sessionId]; }), }; - private readonly credentials: Record< - string, - { credential: string; expiresAt: number } | undefined - > = {}; + private readonly webuiClient = new NapcatWebuiHttpClient({ + getTimeoutMs: () => this.getTimeout(), + }); constructor( private readonly configService: ConfigService, @@ -1274,8 +1272,12 @@ export class QqbotNapcatLoginService { path: string, body: Record = {}, ) { - const credential = await this.getCredential(container); - return this.requestNapcat(container, path, body, credential); + return this.webuiClient + .post(container, path, body) + .catch((err): never => { + const message = this.toolsService.getErrorMessage(err); + return throwVbenError(message || 'NapCat 请求失败'); + }); } private async prepareReloginQrcode( @@ -1866,7 +1868,7 @@ export class QqbotNapcatLoginService { return; } - delete this.credentials[this.getCredentialCacheKey(container)]; + this.webuiClient.clearCredential(container); if (options.waitForReady === false) return; onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动'); @@ -1891,112 +1893,13 @@ export class QqbotNapcatLoginService { } } - delete this.credentials[this.getCredentialCacheKey(container)]; + this.webuiClient.clearCredential(container); if (options.waitForReady === false) return; await this.toolsService.sleep(this.getRestartDelayMs()); await this.getLoginStatus(container, true); } - private getCredentialCacheKey(container: QqbotNapcatRuntime) { - return container.id || container.baseUrl; - } - - private async getCredential(container: QqbotNapcatRuntime) { - const cacheKey = this.getCredentialCacheKey(container); - const cached = this.credentials[cacheKey]; - if (cached && Date.now() < cached.expiresAt) { - return cached.credential; - } - - const token = this.getWebuiToken(container); - const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); - const data = await this.requestNapcat( - container, - '/api/auth/login', - { hash }, - ); - if (!data.Credential) { - throwVbenError('NapCat WebUI 登录失败'); - } - this.credentials[cacheKey] = { - credential: data.Credential, - expiresAt: Date.now() + 50 * 60 * 1000, - }; - return data.Credential; - } - - private requestNapcat( - container: QqbotNapcatRuntime, - path: string, - body: Record = {}, - credential?: string, - ): Promise { - const baseUrl = container.baseUrl; - const target = new URL(path, baseUrl); - const payload = JSON.stringify(body); - const client = target.protocol === 'https:' ? https : http; - - return new Promise((resolve, reject) => { - const req = client.request( - { - headers: { - ...(credential - ? { - Authorization: `Bearer ${credential}`, - } - : {}), - 'Content-Length': Buffer.byteLength(payload), - 'Content-Type': 'application/json', - }, - hostname: target.hostname, - method: 'POST', - path: `${target.pathname}${target.search}`, - port: target.port, - protocol: target.protocol, - timeout: this.getTimeout(), - }, - (res) => { - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); - res.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf8'); - let result: NapcatApiResponse; - try { - result = raw ? JSON.parse(raw) : ({ code: -1 } as any); - } catch { - reject(new Error('NapCat 返回非 JSON 响应')); - return; - } - if (result.code !== 0) { - reject(new Error(result.message || 'NapCat 请求失败')); - return; - } - resolve(result.data as T); - }); - }, - ); - req.on('error', reject); - req.on('timeout', () => { - req.destroy(new Error('NapCat 请求超时')); - }); - req.write(payload); - req.end(); - }).catch((err): never => { - const message = this.toolsService.getErrorMessage(err); - return throwVbenError(message || 'NapCat 请求失败'); - }); - } - - private getWebuiToken(container: QqbotNapcatRuntime) { - const value = container.webuiToken || ''; - const token = `${value}`.trim(); - if (!token) { - throwVbenError('NapCat WebUI token 未配置'); - } - return token; - } - private getSessionTtlMs() { return Number( this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000, diff --git a/src/modules/qqbot/napcat/contract/qqbot-napcat-login.controller.ts b/src/modules/qqbot/napcat/contract/qqbot-napcat-login.controller.ts new file mode 100644 index 0000000..46ba6e6 --- /dev/null +++ b/src/modules/qqbot/napcat/contract/qqbot-napcat-login.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Sse, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { QqbotNapcatLoginService } from '../application/login/qqbot-napcat-login.service'; +import { + QqbotNapcatScanCaptchaDto, + QqbotNapcatScanStatusDto, +} from './qqbot-napcat-login.dto'; + +@ApiTags('QQBot - NapCat 登录') +@Controller('qqbot/account') +@UseGuards(JwtAuthGuard) +export class QqbotNapcatLoginController { + constructor(private readonly napcatLoginService: QqbotNapcatLoginService) {} + + @Post('scan/create') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '扫码新增 QQBot 账号' }) + async scanCreate() { + return vbenSuccess(await this.napcatLoginService.startCreate()); + } + + @Post('scan/refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '扫码刷新 QQBot 账号登录态' }) + @ApiQuery({ name: 'id', type: String }) + async scanRefresh(@Query('id') id: string) { + return vbenSuccess(await this.napcatLoginService.startRefresh(id)); + } + + @Get('scan/status') + @ApiOperation({ summary: '查询 QQBot 扫码登录状态' }) + async scanStatus(@Query() query: QqbotNapcatScanStatusDto) { + return vbenSuccess(await this.napcatLoginService.status(query.sessionId)); + } + + @Sse('scan/events') + @ApiOperation({ summary: '订阅 QQBot 扫码登录进度' }) + scanEvents(@Query() query: QqbotNapcatScanStatusDto) { + return this.napcatLoginService.events(query.sessionId); + } + + @Post('scan/qrcode/refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '刷新 QQBot 扫码二维码' }) + async refreshScanQrcode(@Query() query: QqbotNapcatScanStatusDto) { + return vbenSuccess( + await this.napcatLoginService.refreshQrcode(query.sessionId), + ); + } + + @Post('scan/captcha/submit') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '提交 QQBot 登录安全验证码' }) + async submitScanCaptcha(@Body() body: QqbotNapcatScanCaptchaDto) { + return vbenSuccess( + await this.napcatLoginService.submitCaptcha(body.sessionId, body), + ); + } + + @Post('scan/cancel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '取消 QQBot 扫码登录会话' }) + async cancelScan(@Query() query: QqbotNapcatScanStatusDto) { + return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId)); + } +} diff --git a/src/modules/qqbot/napcat/contract/qqbot-napcat-login.dto.ts b/src/modules/qqbot/napcat/contract/qqbot-napcat-login.dto.ts new file mode 100644 index 0000000..f9031d2 --- /dev/null +++ b/src/modules/qqbot/napcat/contract/qqbot-napcat-login.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class QqbotNapcatScanStatusDto { + @ApiProperty() + sessionId: string; +} + +export class QqbotNapcatScanCaptchaDto extends QqbotNapcatScanStatusDto { + @ApiProperty() + randstr: string; + + @ApiPropertyOptional() + sid?: string; + + @ApiProperty() + ticket: string; +} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 87375f5..1fd274c 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -1,3 +1,6 @@ +export * from './qqbot-napcat.module'; +export * from './contract/qqbot-napcat-login.controller'; +export * from './contract/qqbot-napcat-login.dto'; export * from './infrastructure/persistence/napcat-account-binding.entity'; export * from './infrastructure/persistence/napcat-container.entity'; export * from './infrastructure/persistence/napcat-device-identity.entity'; @@ -6,8 +9,6 @@ export * from './infrastructure/persistence/napcat-login-session.entity'; export * from './infrastructure/persistence/napcat-login-state-store.service'; export * from './infrastructure/persistence/napcat-runtime-cleanup.entity'; export * from './infrastructure/integration/device/napcat-device-identity.service'; -export * from './infrastructure/persistence/qqbot-account-napcat.entity'; -export * from './infrastructure/persistence/qqbot-napcat-container.entity'; export * from './infrastructure/integration/napcat-login-api.client'; export * from './domain/login/napcat-login-state-machine'; export * from './application/login/qqbot-napcat-login.service'; diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index e70fb81..adeca9a 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -13,8 +13,8 @@ import { } from './napcat-docker-device-options'; import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.service'; import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; -import { QqbotAccountNapcat } from '../../persistence/qqbot-account-napcat.entity'; -import { QqbotNapcatContainer } from '../../persistence/qqbot-napcat-container.entity'; +import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity'; +import { NapcatContainer } from '../../persistence/napcat-container.entity'; import type { NapcatApiResponse, NapcatCredential, @@ -52,10 +52,10 @@ type NapcatAutoLoginResult = { export class QqbotNapcatContainerService { constructor( private readonly configService: ConfigService, - @InjectRepository(QqbotNapcatContainer) - private readonly containerRepository: Repository, - @InjectRepository(QqbotAccountNapcat) - private readonly bindingRepository: Repository, + @InjectRepository(NapcatContainer) + private readonly containerRepository: Repository, + @InjectRepository(NapcatAccountBinding) + private readonly bindingRepository: Repository, private readonly toolsService: ToolsService, private readonly deviceIdentityService?: NapcatDeviceIdentityService, ) {} @@ -169,7 +169,7 @@ export class QqbotNapcatContainerService { } private async resolveRuntimeDeviceIdentity( - container: QqbotNapcatContainer, + container: NapcatContainer, selfId: string, ): Promise { if ( @@ -199,7 +199,7 @@ export class QqbotNapcatContainerService { } async tryAutoLogin( - container: QqbotNapcatContainer, + container: NapcatContainer, options: NapcatLoginEnvOptions, ): Promise { const selfId = this.toolsService.toTrimmedString(options.selfId); @@ -334,8 +334,6 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" const existing = await this.bindingRepository.findOne({ where: { accountId, - containerId, - isDeleted: false, }, }); if (existing) { @@ -343,8 +341,11 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" { id: existing.id }, { bindStatus: 'bound', + containerId, isPrimary: true, + isDeleted: false, lastLoginAt: new Date(), + remark: '', }, ); await this.removeOtherAccountContainers(accountId, containerId); @@ -475,7 +476,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" return true; } - async detectRuntimeOffline(container: QqbotNapcatContainer) { + async detectRuntimeOffline(container: NapcatContainer) { if (this.getManagedMode() !== 'ssh' || !container.name) return null; try { @@ -549,7 +550,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" } async inspectRuntimeStatus( - container: QqbotNapcatContainer, + container: NapcatContainer, ): Promise { const checkedAt = new Date(); const containerOnline = container.status === 'running'; @@ -694,12 +695,12 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" } } - private async removeRemoteDockerContainer(container: QqbotNapcatContainer) { + private async removeRemoteDockerContainer(container: NapcatContainer) { const script = this.buildRemoteRemoveScript(container); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); } - private buildRemoteRemoveScript(container: QqbotNapcatContainer) { + private buildRemoteRemoveScript(container: NapcatContainer) { const dataDir = this.sh(container.dataDir || ''); const name = this.sh(container.name); const rootDir = this.sh(this.getRootDir()); @@ -725,7 +726,7 @@ fi `; } - private buildRemoteResetLoginStateScript(container: QqbotNapcatContainer) { + private buildRemoteResetLoginStateScript(container: NapcatContainer) { const dataDir = this.sh(container.dataDir || ''); const name = this.sh(container.name); const rootDir = this.sh(this.getRootDir()); @@ -762,7 +763,7 @@ echo "__KT_PROGRESS__:container-started:NapCat 容器已启动" `; } - private buildRemoteRecentLogsScript(container: QqbotNapcatContainer) { + private buildRemoteRecentLogsScript(container: NapcatContainer) { return this.buildRemoteRecentLogsByNameScript(container.name); } @@ -927,6 +928,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true const container = await this.containerRepository.save( this.containerRepository.create({ baseUrl, + accountId: accountId || null, dataDir, image, isDeleted: false, @@ -1194,7 +1196,7 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\ }; } - private toRuntime(container: QqbotNapcatContainer): QqbotNapcatRuntime { + private toRuntime(container: NapcatContainer): QqbotNapcatRuntime { return { baseUrl: this.normalizeBaseUrl(container.baseUrl), id: container.id, diff --git a/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts b/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts index 403bb60..a25b23a 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/napcat-login-api.client.ts @@ -1,3 +1,6 @@ +import { createHash } from 'crypto'; +import * as http from 'http'; +import * as https from 'https'; import * as QRCode from 'qrcode'; export type NewDeviceQrStatus = @@ -33,6 +36,142 @@ export type NapcatLoginApiTransport = { post(path: string, body: Record): Promise; }; +export type NapcatWebuiRuntime = { + baseUrl: string; + id?: string; + webuiToken?: null | string; +}; + +type NapcatApiResponse = { + code: number; + data?: T; + message?: string; +}; + +type NapcatCredential = { + Credential?: string; +}; + +export class NapcatWebuiHttpClient { + private readonly credentials: Record< + string, + { credential: string; expiresAt: number } | undefined + > = {}; + + constructor( + private readonly options: { + getTimeoutMs: () => number; + }, + ) {} + + async post( + container: NapcatWebuiRuntime, + path: string, + body: Record = {}, + ) { + const credential = await this.getCredential(container); + return this.request(container, path, body, credential); + } + + clearCredential(container: NapcatWebuiRuntime) { + delete this.credentials[this.getCredentialCacheKey(container)]; + } + + private getCredentialCacheKey(container: NapcatWebuiRuntime) { + return container.id || container.baseUrl; + } + + private async getCredential(container: NapcatWebuiRuntime) { + const cacheKey = this.getCredentialCacheKey(container); + const cached = this.credentials[cacheKey]; + if (cached && Date.now() < cached.expiresAt) { + return cached.credential; + } + + const token = this.getWebuiToken(container); + const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); + const data = await this.request( + container, + '/api/auth/login', + { hash }, + ); + if (!data.Credential) { + throw new Error('NapCat WebUI 登录失败'); + } + this.credentials[cacheKey] = { + credential: data.Credential, + expiresAt: Date.now() + 50 * 60 * 1000, + }; + return data.Credential; + } + + private request( + container: NapcatWebuiRuntime, + path: string, + body: Record = {}, + credential?: string, + ): Promise { + const target = new URL(path, container.baseUrl); + const payload = JSON.stringify(body); + const client = target.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = client.request( + { + headers: { + ...(credential + ? { + Authorization: `Bearer ${credential}`, + } + : {}), + 'Content-Length': Buffer.byteLength(payload), + 'Content-Type': 'application/json', + }, + hostname: target.hostname, + method: 'POST', + path: `${target.pathname}${target.search}`, + port: target.port, + protocol: target.protocol, + timeout: this.options.getTimeoutMs(), + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let result: NapcatApiResponse; + try { + result = raw ? JSON.parse(raw) : ({ code: -1 } as any); + } catch { + reject(new Error('NapCat 返回非 JSON 响应')); + return; + } + if (result.code !== 0) { + reject(new Error(result.message || 'NapCat 请求失败')); + return; + } + resolve(result.data as T); + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(new Error('NapCat 请求超时')); + }); + req.write(payload); + req.end(); + }); + } + + private getWebuiToken(container: NapcatWebuiRuntime) { + const token = `${container.webuiToken || ''}`.trim(); + if (!token) { + throw new Error('NapCat WebUI token 未配置'); + } + return token; + } +} + export class NapcatLoginApiClient { constructor(private readonly transport: NapcatLoginApiTransport) {} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts index 1818c91..e97527a 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts @@ -4,8 +4,6 @@ import { NapcatDeviceIdentity } from './napcat-device-identity.entity'; import { NapcatLoginChallengeEntity } from './napcat-login-challenge.entity'; import { NapcatLoginSession } from './napcat-login-session.entity'; import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity'; -import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; -import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { tables: [ @@ -25,6 +23,4 @@ export const NAPCAT_RUNTIME_ENTITIES = [ NapcatLoginChallengeEntity, NapcatLoginSession, NapcatRuntimeCleanup, - QqbotAccountNapcat, - QqbotNapcatContainer, ]; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts index 2e6b0d9..47a70ba 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts @@ -3,12 +3,14 @@ import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, + KtDateTimeColumn, KtUpdateDateColumn, } from '@/common'; import type { QqbotAccountNapcatBindStatus } from '@/modules/qqbot/core/contract/qqbot.types'; @Entity('napcat_account_binding') @Index('uk_napcat_account_binding_account', ['accountId'], { unique: true }) +@Index('idx_napcat_account_binding_container', ['containerId', 'isDeleted']) export class NapcatAccountBinding { @PrimaryColumn({ type: 'bigint' }) id: string; @@ -19,11 +21,33 @@ export class NapcatAccountBinding { @Column({ name: 'container_id', type: 'bigint' }) containerId: string; - @Column({ name: 'device_identity_id', type: 'bigint' }) - deviceIdentityId: string; + @Column({ + default: null, + name: 'device_identity_id', + nullable: true, + type: 'bigint', + }) + deviceIdentityId: null | string; - @Column({ length: 32 }) - status: QqbotAccountNapcatBindStatus; + @Column({ default: 'pending', length: 32, name: 'status' }) + bindStatus: QqbotAccountNapcatBindStatus; + + @Column({ default: true, name: 'is_primary' }) + isPrimary: boolean; + + @KtDateTimeColumn({ + default: null, + name: 'last_login_at', + nullable: true, + type: 'datetime', + }) + lastLoginAt: KtDateTime | null; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; @KtCreateDateColumn({ name: 'create_time' }) createTime: KtDateTime; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts index a018387..f70f66e 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts @@ -3,28 +3,77 @@ import { ensureSnowflakeId, KtCreateDateColumn, KtDateTime, + KtDateTimeColumn, KtUpdateDateColumn, } from '@/common'; import type { QqbotNapcatContainerStatus } from '@/modules/qqbot/core/contract/qqbot.types'; @Entity('napcat_container') -@Index('uk_napcat_container_name', ['containerName'], { unique: true }) +@Index('uk_napcat_container_name', ['name'], { unique: true }) +@Index('idx_napcat_container_status', ['status', 'isDeleted']) +@Index('idx_napcat_container_account', ['accountId']) export class NapcatContainer { @PrimaryColumn({ type: 'bigint' }) id: string; - @Column({ name: 'account_id', type: 'bigint' }) - accountId: string; + @Column({ name: 'account_id', nullable: true, type: 'bigint' }) + accountId: null | string; - @Column({ length: 128, name: 'container_name' }) - containerName: string; + @Column({ length: 120, name: 'container_name' }) + name: string; - @Column({ length: 255, name: 'image_name' }) - imageName: string; + @Column({ length: 255, name: 'base_url' }) + baseUrl: string; - @Column({ length: 32 }) + @Column({ name: 'webui_port', nullable: true, type: 'int' }) + webuiPort: null | number; + + @Column({ + default: null, + length: 255, + name: 'webui_token', + nullable: true, + select: false, + }) + webuiToken: null | string; + + @Column({ default: '', length: 255 }) + image: string; + + @Column({ default: '', length: 500, name: 'data_dir' }) + dataDir: string; + + @Column({ default: '', length: 500, name: 'reverse_ws_url' }) + reverseWsUrl: string; + + @Column({ default: 'creating', length: 32 }) status: QqbotNapcatContainerStatus; + @KtDateTimeColumn({ + default: null, + name: 'last_started_at', + nullable: true, + type: 'datetime', + }) + lastStartedAt: KtDateTime | null; + + @KtDateTimeColumn({ + default: null, + name: 'last_checked_at', + nullable: true, + type: 'datetime', + }) + lastCheckedAt: KtDateTime | null; + + @Column({ default: null, length: 500, name: 'last_error', nullable: true }) + lastError: null | string; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + @KtCreateDateColumn({ name: 'create_time' }) createTime: KtDateTime; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts index cb892da..4df7ff3 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -55,8 +55,11 @@ export class NapcatLoginStateStoreService { where: { sessionKey: sessionId }, }); const session = persisted?.sessionPayload; - if (session) this.cache[session.id] = session; - return session; + if (!session) return undefined; + + const hydratedSession = await this.hydratePersistedSession(session); + this.cache[hydratedSession.id] = hydratedSession; + return hydratedSession; } set(session: QqbotLoginScanSession) { @@ -180,6 +183,97 @@ export class NapcatLoginStateStoreService { ); } + private async hydratePersistedSession(session: QqbotLoginScanSession) { + const hydratedSession = { ...session }; + + await this.hydrateCaptchaChallenge(hydratedSession); + await this.hydrateNewDeviceChallenge(hydratedSession); + await this.hydrateRuntimeCleanup(hydratedSession); + + return hydratedSession; + } + + private async hydrateCaptchaChallenge(session: QqbotLoginScanSession) { + const challenge = await this.findChallenge(session.id, 'captcha'); + if (!challenge || challenge.status !== 'pending') return; + + const payload = this.toChallengePayload(challenge.challengePayload); + if (!session.captchaUrl && challenge.challengeUrl) { + session.captchaUrl = challenge.challengeUrl; + } + if (!session.expectedSelfId && typeof payload.expectedSelfId === 'string') { + session.expectedSelfId = payload.expectedSelfId; + } + session.errorMessage = session.errorMessage || '需要验证码'; + } + + private async hydrateNewDeviceChallenge(session: QqbotLoginScanSession) { + const challenge = await this.findChallenge(session.id, 'new-device'); + if (!challenge || this.isResolvedChallenge(challenge.status)) return; + + const payload = this.toChallengePayload(challenge.challengePayload); + session.newDeviceStatus = challenge.status as QqbotLoginScanSession['newDeviceStatus']; + if ( + !session.deviceVerifyUrl && + typeof payload.deviceVerifyUrl === 'string' + ) { + session.deviceVerifyUrl = payload.deviceVerifyUrl; + } + if ( + !session.newDevicePullQrCodeSig && + typeof payload.newDevicePullQrCodeSig === 'string' + ) { + session.newDevicePullQrCodeSig = payload.newDevicePullQrCodeSig; + } + if (!session.newDeviceQrcode) { + session.newDeviceQrcode = + typeof payload.newDeviceQrcode === 'string' + ? payload.newDeviceQrcode + : challenge.challengeUrl || undefined; + } + session.errorMessage = session.errorMessage || '需要新设备验证二维码'; + } + + private async hydrateRuntimeCleanup(session: QqbotLoginScanSession) { + if (!this.runtimeCleanupRepository) return; + const cleanup = await this.runtimeCleanupRepository.findOne({ + order: { createTime: 'DESC' }, + where: { + cleanupType: 'password-login-env', + sessionId: session.id, + status: 'failed', + }, + } as any); + if (!cleanup) return; + + session.status = 'error'; + session.captchaUrl = undefined; + session.errorMessage = + cleanup.errorMessage || session.errorMessage || '运行态密码清理失败'; + session.passwordMd5 = undefined; + session.preparingRelogin = false; + } + + private async findChallenge( + sessionId: string, + challengeType: NapcatLoginChallengeType, + ) { + if (!this.loginChallengeRepository) return null; + return this.loginChallengeRepository.findOne({ + order: { createTime: 'DESC' }, + where: { + challengeType, + sessionId, + }, + } as any); + } + + private toChallengePayload(payload: unknown) { + return payload && typeof payload === 'object' + ? (payload as Record) + : {}; + } + private async saveChallenge(input: { challengePayload: null | Record; challengeType: NapcatLoginChallengeType; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity.ts deleted file mode 100644 index c23519e..0000000 --- a/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; -import { - ensureSnowflakeId, - KtCreateDateColumn, - KtDateTime, - KtDateTimeColumn, - KtUpdateDateColumn, -} from '@/common'; -import type { QqbotAccountNapcatBindStatus } from '@/modules/qqbot/core/contract/qqbot.types'; - -@Entity('qqbot_account_napcat') -@Index('idx_qqbot_account_napcat_account', ['accountId', 'isDeleted']) -@Index('idx_qqbot_account_napcat_container', ['containerId', 'isDeleted']) -export class QqbotAccountNapcat { - @PrimaryColumn({ type: 'bigint' }) - id: string; - - @Column({ name: 'account_id', type: 'bigint' }) - accountId: string; - - @Column({ name: 'container_id', type: 'bigint' }) - containerId: string; - - @Column({ default: 'pending', length: 32, name: 'bind_status' }) - bindStatus: QqbotAccountNapcatBindStatus; - - @Column({ default: true, name: 'is_primary' }) - isPrimary: boolean; - - @KtDateTimeColumn({ - default: null, - name: 'last_login_at', - nullable: true, - type: 'datetime', - }) - lastLoginAt: KtDateTime | null; - - @Column({ default: '', length: 255 }) - remark: string; - - @Column({ default: false, name: 'is_deleted' }) - isDeleted: boolean; - - @KtCreateDateColumn({ name: 'create_time' }) - createTime: KtDateTime; - - @KtUpdateDateColumn({ name: 'update_time' }) - updateTime: KtDateTime; - - @BeforeInsert() - createId() { - ensureSnowflakeId(this); - } -} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity.ts deleted file mode 100644 index 5a40243..0000000 --- a/src/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; -import { - ensureSnowflakeId, - KtCreateDateColumn, - KtDateTime, - KtDateTimeColumn, - KtUpdateDateColumn, -} from '@/common'; -import type { QqbotNapcatContainerStatus } from '@/modules/qqbot/core/contract/qqbot.types'; - -@Entity('qqbot_napcat_container') -@Index('uk_qqbot_napcat_container_name', ['name'], { unique: true }) -@Index('idx_qqbot_napcat_container_status', ['status', 'isDeleted']) -export class QqbotNapcatContainer { - @PrimaryColumn({ type: 'bigint' }) - id: string; - - @Column({ length: 120 }) - name: string; - - @Column({ length: 255, name: 'base_url' }) - baseUrl: string; - - @Column({ name: 'webui_port', nullable: true, type: 'int' }) - webuiPort: null | number; - - @Column({ - default: null, - length: 255, - name: 'webui_token', - nullable: true, - select: false, - }) - webuiToken: null | string; - - @Column({ default: '', length: 255 }) - image: string; - - @Column({ default: '', length: 500, name: 'data_dir' }) - dataDir: string; - - @Column({ default: '', length: 500, name: 'reverse_ws_url' }) - reverseWsUrl: string; - - @Column({ default: 'creating', length: 32 }) - status: QqbotNapcatContainerStatus; - - @KtDateTimeColumn({ - default: null, - name: 'last_started_at', - nullable: true, - type: 'datetime', - }) - lastStartedAt: KtDateTime | null; - - @KtDateTimeColumn({ - default: null, - name: 'last_checked_at', - nullable: true, - type: 'datetime', - }) - lastCheckedAt: KtDateTime | null; - - @Column({ default: null, length: 500, name: 'last_error', nullable: true }) - lastError: null | string; - - @Column({ default: '', length: 255 }) - remark: string; - - @Column({ default: false, name: 'is_deleted' }) - isDeleted: boolean; - - @KtCreateDateColumn({ name: 'create_time' }) - createTime: KtDateTime; - - @KtUpdateDateColumn({ name: 'update_time' }) - updateTime: KtDateTime; - - @BeforeInsert() - createId() { - ensureSnowflakeId(this); - } -} diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts new file mode 100644 index 0000000..144faf9 --- /dev/null +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -0,0 +1,46 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module'; +import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; +import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service'; +import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service'; +import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; +import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service'; +import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service'; +import { + NAPCAT_RUNTIME_ENTITIES, +} from './infrastructure/persistence'; +import { NapcatLoginStateStoreService } from './infrastructure/persistence/napcat-login-state-store.service'; + +export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES]; + +export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController]; + +export const QQBOT_NAPCAT_PROVIDERS = [ + NapcatDeviceIdentityService, + NapcatLoginStateStoreService, + QqbotNapcatContainerService, + QqbotNapcatLoginService, + QqbotNapcatWatchdogService, +]; + +export const QQBOT_NAPCAT_EXPORTS = [ + NapcatDeviceIdentityService, + NapcatLoginStateStoreService, + QqbotNapcatContainerService, + QqbotNapcatLoginService, +]; + +@Module({ + imports: [ + ConfigModule, + AdminAuthGuardModule, + forwardRef(() => QqbotCoreModule), + TypeOrmModule.forFeature(QQBOT_NAPCAT_ENTITIES), + ], + controllers: QQBOT_NAPCAT_CONTROLLERS, + providers: QQBOT_NAPCAT_PROVIDERS, + exports: [TypeOrmModule, ...QQBOT_NAPCAT_EXPORTS], +}) +export class QqbotNapcatModule {} diff --git a/src/modules/qqbot/napcat/schema/README.md b/src/modules/qqbot/napcat/schema/README.md index 3dd2612..e0aba07 100644 --- a/src/modules/qqbot/napcat/schema/README.md +++ b/src/modules/qqbot/napcat/schema/README.md @@ -1,15 +1,7 @@ # QQBot NapCat Schema NapCat owns container runtime, account binding, device identity, login session, -challenge, and cleanup persistence. - -Current compatibility tables: - -- `qqbot_account_napcat` -- `qqbot_napcat_container` -- `napcat_device_identity` - -Target v3 tables: +challenge, and cleanup persistence through the v3 `napcat_*` tables. - `napcat_container` - `napcat_device_identity` @@ -21,7 +13,7 @@ Target v3 tables: Verification SQL: ```sql +SELECT COUNT(*) FROM napcat_container WHERE is_deleted = 0; SELECT COUNT(*) FROM napcat_device_identity; -SELECT COUNT(*) FROM qqbot_account_napcat WHERE is_deleted = 0; -SELECT COUNT(*) FROM qqbot_napcat_container WHERE is_deleted = 0; +SELECT COUNT(*) FROM napcat_account_binding WHERE is_deleted = 0; ``` diff --git a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts index 21088ab..340c31d 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts @@ -13,6 +13,9 @@ import { type QqbotPluginManifest, } from '../domain/manifest'; import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime'; +import { QqbotPluginPackageReaderService } from '../infrastructure/integration/package/plugin-package-reader.service'; +import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.service'; +import { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service'; import { QqbotPlugin, QqbotPluginAccountBinding, @@ -106,6 +109,12 @@ export class QqbotPluginPlatformService { @Optional() @Inject(QQBOT_PLUGIN_RUNTIME_FACTORY) private readonly runtimeFactory?: QqbotPluginRuntimeFactory, + @Optional() + private readonly pluginRegistry?: QqbotPluginRegistryService, + @Optional() + private readonly eventPluginRegistry?: QqbotEventPluginRegistryService, + @Optional() + private readonly packageReader?: QqbotPluginPackageReaderService, ) {} async listInstallations() { @@ -163,8 +172,16 @@ export class QqbotPluginPlatformService { }; } + uploadPackage(body: InstallLocalBody) { + return { + ...this.requirePackageReader().readPackage(body), + valid: true, + }; + } + async installLocal(body: InstallLocalBody) { - const manifest = parseQqbotPluginManifest(body.manifest); + const pluginPackage = this.requirePackageReader().readPackage(body); + const manifest = pluginPackage.manifest; const plugin = await this.pluginRepository.save({ pluginKey: manifest.pluginKey, pluginName: manifest.name, @@ -173,7 +190,7 @@ export class QqbotPluginPlatformService { }); const version = await this.versionRepository.save({ manifestJson: manifest, - packageHash: body.packageHash || 'local-dev-package', + packageHash: pluginPackage.packageHash, pluginId: plugin.id, version: manifest.version, }); @@ -181,7 +198,7 @@ export class QqbotPluginPlatformService { await this.persistManifestCapabilities(plugin.id, manifest); return this.installationRepository.save({ - installedPath: body.packagePath || '', + installedPath: pluginPackage.packagePath, pluginId: plugin.id, runtimeStatus: 'stopped', status: 'installed', @@ -414,6 +431,7 @@ export class QqbotPluginPlatformService { ) { const activeOperation = enabled; const activeEvent = enabled; + const pluginKey = await this.getPluginKey(installation.pluginId); await Promise.all([ this.operationRepository.update( { pluginId: installation.pluginId }, @@ -424,6 +442,16 @@ export class QqbotPluginPlatformService { { enabled: activeEvent }, ), ]); + this.pluginRegistry?.setPluginActive(pluginKey, enabled); + this.eventPluginRegistry?.setPluginActive(pluginKey, enabled); + } + + private async getPluginKey(pluginId: string) { + const findOne = this.pluginRepository.findOne?.bind( + this.pluginRepository, + ); + const plugin = findOne ? await findOne({ where: { id: pluginId } }) : null; + return plugin?.pluginKey || pluginId; } private async stopWorker(installationId: string) { @@ -501,4 +529,11 @@ export class QqbotPluginPlatformService { } return {}; } + + private requirePackageReader() { + if (!this.packageReader) { + throwVbenError('插件包读取器未初始化'); + } + return this.packageReader; + } } diff --git a/src/modules/qqbot/plugin-platform/application/registry/plugin-installation-state.ts b/src/modules/qqbot/plugin-platform/application/registry/plugin-installation-state.ts new file mode 100644 index 0000000..f0846fc --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/registry/plugin-installation-state.ts @@ -0,0 +1,42 @@ +import type { + QqbotPlugin, + QqbotPluginInstallation, +} from '@/modules/qqbot/plugin-platform/infrastructure/persistence'; + +type PluginStateRow = Pick; +type InstallationStateRow = Pick< + QqbotPluginInstallation, + 'pluginId' | 'status' +>; + +export function resolveInactivePluginKeys( + plugins: PluginStateRow[], + installations: InstallationStateRow[], +) { + const pluginKeysById = new Map( + plugins.map((plugin) => [plugin.id, plugin.pluginKey] as const), + ); + const statesByPluginKey = new Map< + string, + { hasEnabled: boolean; hasInactive: boolean } + >(); + + for (const installation of installations) { + const pluginKey = pluginKeysById.get(installation.pluginId); + if (!pluginKey) continue; + const state = statesByPluginKey.get(pluginKey) || { + hasEnabled: false, + hasInactive: false, + }; + if (installation.status === 'enabled') { + state.hasEnabled = true; + } else { + state.hasInactive = true; + } + statesByPluginKey.set(pluginKey, state); + } + + return [...statesByPluginKey.entries()] + .filter(([, state]) => !state.hasEnabled && state.hasInactive) + .map(([pluginKey]) => pluginKey); +} diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts index 5c4511b..87f4f4c 100644 --- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts +++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts @@ -1,4 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { formatKtDateTime, throwVbenError } from '@/common'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import type { @@ -9,35 +11,74 @@ import type { } from '@/modules/qqbot/core/contract/qqbot.types'; import { QqbotBuiltinPluginPackageLoaderService, - type QqbotRepeaterPluginPackage, + type QqbotEventPluginPackage, } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; +import { + QqbotPlugin, + QqbotPluginInstallation, +} from '@/modules/qqbot/plugin-platform/infrastructure/persistence'; +import { resolveInactivePluginKeys } from './plugin-installation-state'; @Injectable() -export class QqbotEventPluginRegistryService { - private repeaterPlugin?: QqbotRepeaterPluginPackage; +export class QqbotEventPluginRegistryService implements OnModuleInit { + private readonly eventPlugins = new Map(); + private readonly inactivePluginKeys = new Set(); constructor( private readonly accountService: QqbotAccountService, private readonly builtinPluginLoader: QqbotBuiltinPluginPackageLoaderService, + @Optional() + @InjectRepository(QqbotPlugin) + private readonly pluginRepository?: Repository, + @Optional() + @InjectRepository(QqbotPluginInstallation) + private readonly installationRepository?: Repository, ) {} + async onModuleInit() { + this.loadBuiltinEventPlugins(); + await this.hydrateInactivePluginKeys(); + } + + registerEventPlugin(plugin: QqbotEventPluginPackage) { + const definition = plugin.getDefinition(); + if (!definition.key) { + throwVbenError('QQBot 事件插件必须包含 key'); + } + if (this.eventPlugins.has(definition.key)) { + throwVbenError(`QQBot 事件插件重复:${definition.key}`); + } + this.eventPlugins.set(definition.key, plugin); + } + listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { return this.getDefinitions(pluginKey); } + setPluginActive(pluginKey: string, active: boolean) { + if (active) { + this.inactivePluginKeys.delete(pluginKey); + return; + } + this.inactivePluginKeys.add(pluginKey); + } + async listPlugins(selfId?: string) { + const plugins = this.getActiveEventPlugins(); const accounts = selfId ? [await this.accountService.findBySelfId(selfId)] : await this.accountService.allEnabled(); return Promise.all( accounts .filter((account): account is NonNullable => !!account) - .map((account) => - this.getRepeaterPlugin().getSummary({ - accountName: account.name, - connectStatus: account.connectStatus, - selfId: account.selfId, - }), + .flatMap((account) => + plugins.map((plugin) => + plugin.getSummary({ + accountName: account.name, + connectStatus: account.connectStatus, + selfId: account.selfId, + }), + ), ), ); } @@ -68,41 +109,80 @@ export class QqbotEventPluginRegistryService { } async dispatchMessage(message: QqbotNormalizedMessage) { - return this.getRepeaterPlugin().handleMessage(message); + let handled = false; + for (const plugin of this.getActiveEventPlugins()) { + const definition = plugin.getDefinition(); + if (definition.triggerType !== 'message') continue; + handled = (await plugin.handleMessage(message)) || handled; + } + return handled; } async bind(pluginKey: string, selfId: string) { - this.assertPlugin(pluginKey); - if (pluginKey === 'repeater') { - return this.getRepeaterPlugin().bind(selfId); - } - await this.accountService.bindEventPlugin(selfId, pluginKey); - return true; + return this.requirePlugin(pluginKey).bind(selfId); } async unbind(pluginKey: string, selfId: string) { - this.assertPlugin(pluginKey); - if (pluginKey === 'repeater') { - return this.getRepeaterPlugin().unbind(selfId); - } - await this.accountService.unbindEventPlugin(selfId, pluginKey); - return true; + return this.requirePlugin(pluginKey).unbind(selfId); } - private assertPlugin(pluginKey: string) { - if (pluginKey === 'repeater') return; - throwVbenError(`QQBot 事件插件不存在:${pluginKey}`); + private requirePlugin(pluginKey: string) { + this.ensureEventPluginsLoaded(); + const plugin = this.eventPlugins.get(pluginKey); + if (!plugin) { + throwVbenError(`QQBot 事件插件不存在:${pluginKey}`); + } + if (!this.isPluginActive(pluginKey)) { + throwVbenError(`QQBot 事件插件未启用:${pluginKey}`); + } + return plugin; } private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { - const definitions = [this.getRepeaterPlugin().getDefinition()]; + const definitions = this.getActiveEventPlugins().map((plugin) => + plugin.getDefinition(), + ); return pluginKey ? definitions.filter((definition) => definition.key === pluginKey) : definitions; } - private getRepeaterPlugin() { - this.repeaterPlugin ||= this.builtinPluginLoader.loadRepeaterPlugin(); - return this.repeaterPlugin; + private isPluginActive(pluginKey: string) { + return !this.inactivePluginKeys.has(pluginKey); + } + + private getActiveEventPlugins() { + this.ensureEventPluginsLoaded(); + return [...this.eventPlugins.entries()] + .filter(([pluginKey]) => this.isPluginActive(pluginKey)) + .map(([, plugin]) => plugin); + } + + private ensureEventPluginsLoaded() { + if (this.eventPlugins.size) return; + this.loadBuiltinEventPlugins(); + } + + private loadBuiltinEventPlugins() { + for (const plugin of this.builtinPluginLoader.loadEventPlugins()) { + if (!this.eventPlugins.has(plugin.getDefinition().key)) { + this.registerEventPlugin(plugin); + } + } + } + + private async hydrateInactivePluginKeys() { + if (!this.pluginRepository || !this.installationRepository) return; + + const [plugins, installations] = await Promise.all([ + this.pluginRepository.find(), + this.installationRepository.find(), + ]); + for (const pluginKey of resolveInactivePluginKeys( + plugins, + installations, + )) { + this.setPluginActive(pluginKey, false); + } } } diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts index 47899ae..3756175 100644 --- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts +++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts @@ -1,4 +1,6 @@ import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { formatKtDateTime, throwVbenError } from '@/common'; import type { QqbotIntegrationPlugin, @@ -8,21 +10,34 @@ import type { QqbotPluginSummary, } from '@/modules/qqbot/core/contract/qqbot.types'; import { QqbotBuiltinPluginPackageLoaderService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; +import { + QqbotPlugin, + QqbotPluginInstallation, +} from '@/modules/qqbot/plugin-platform/infrastructure/persistence'; +import { resolveInactivePluginKeys } from './plugin-installation-state'; @Injectable() export class QqbotPluginRegistryService implements OnModuleInit { + private readonly inactivePluginKeys = new Set(); private readonly pluginAliases = new Map(); private readonly plugins = new Map(); constructor( @Optional() private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService, + @Optional() + @InjectRepository(QqbotPlugin) + private readonly pluginRepository?: Repository, + @Optional() + @InjectRepository(QqbotPluginInstallation) + private readonly installationRepository?: Repository, ) {} - onModuleInit() { + async onModuleInit() { for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) { this.register(plugin); } + await this.hydrateInactivePluginKeys(); } register(plugin: QqbotIntegrationPlugin) { @@ -39,20 +54,32 @@ export class QqbotPluginRegistryService implements OnModuleInit { } } + setPluginActive(pluginKey: string, active: boolean) { + const canonicalKey = this.resolveCanonicalPluginKey(pluginKey); + if (active) { + this.inactivePluginKeys.delete(canonicalKey); + return; + } + this.inactivePluginKeys.add(canonicalKey); + } + listPlugins(): QqbotPluginSummary[] { - return [...this.plugins.values()].map((plugin) => ({ - description: plugin.description, - key: plugin.key, - name: plugin.name, - operationCount: plugin.operations.length, - triggerMode: 'command', - version: plugin.version, - })); + return [...this.plugins.values()] + .filter((plugin) => this.isPluginActive(plugin.key)) + .map((plugin) => ({ + description: plugin.description, + key: plugin.key, + name: plugin.name, + operationCount: plugin.operations.length, + triggerMode: 'command', + version: plugin.version, + })); } listOperations(pluginKey?: string): QqbotPluginOperationSummary[] { return this.getPlugins(pluginKey).flatMap((plugin) => plugin.operations.map((operation) => ({ + aliases: operation.aliases, cacheTtlMs: operation.cacheTtlMs, description: operation.description, inputSchema: operation.inputSchema, @@ -60,6 +87,7 @@ export class QqbotPluginRegistryService implements OnModuleInit { name: operation.name, outputSchema: operation.outputSchema, pluginKey: plugin.key, + timeoutMs: operation.timeoutMs, triggerMode: 'command', })), ); @@ -96,7 +124,7 @@ export class QqbotPluginRegistryService implements OnModuleInit { context: QqbotPluginOperationContext = {}, ) { const operation = this.getOperation(pluginKey, operationKey); - return operation.execute(input, context); + return this.executeWithTimeout(operation, input, context); } assertOperation(pluginKey?: string, operationKey?: string) { @@ -109,6 +137,9 @@ export class QqbotPluginRegistryService implements OnModuleInit { private getOperation(pluginKey: string, operationKey: string) { const plugin = this.getPluginByKey(pluginKey); if (!plugin) throwVbenError(`QQBot 插件不存在:${pluginKey}`); + if (!this.isPluginActive(plugin.key)) { + throwVbenError(`QQBot 插件未启用:${plugin.key}`); + } const operation = plugin.operations.find( (item) => item.key === operationKey, @@ -119,10 +150,44 @@ export class QqbotPluginRegistryService implements OnModuleInit { return operation; } + private async executeWithTimeout( + operation: QqbotIntegrationPlugin['operations'][number], + input: Record, + context: QqbotPluginOperationContext, + ) { + const timeoutMs = Number(operation.timeoutMs || 0); + const execution = Promise.resolve().then(() => + operation.execute(input, context), + ); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return execution; + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + try { + throwVbenError(`QQBot 插件能力执行超时:${operation.key}`); + } catch (error) { + reject(error); + } + }, timeoutMs); + timer.unref?.(); + }); + + try { + return await Promise.race([execution, timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + private getPlugins(pluginKey?: string) { - if (!pluginKey) return [...this.plugins.values()]; + if (!pluginKey) { + return [...this.plugins.values()].filter((plugin) => + this.isPluginActive(plugin.key), + ); + } const plugin = this.getPluginByKey(pluginKey); - return plugin ? [plugin] : []; + return plugin && this.isPluginActive(plugin.key) ? [plugin] : []; } private getPluginByKey(pluginKey: string) { @@ -131,4 +196,27 @@ export class QqbotPluginRegistryService implements OnModuleInit { this.plugins.get(this.pluginAliases.get(pluginKey) || '') ); } + + private isPluginActive(pluginKey: string) { + return !this.inactivePluginKeys.has(this.resolveCanonicalPluginKey(pluginKey)); + } + + private resolveCanonicalPluginKey(pluginKey: string) { + return this.pluginAliases.get(pluginKey) || pluginKey; + } + + private async hydrateInactivePluginKeys() { + if (!this.pluginRepository || !this.installationRepository) return; + + const [plugins, installations] = await Promise.all([ + this.pluginRepository.find(), + this.installationRepository.find(), + ]); + for (const pluginKey of resolveInactivePluginKeys( + plugins, + installations, + )) { + this.setPluginActive(pluginKey, false); + } + } } diff --git a/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts b/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts index 5f7ee75..aab4073 100644 --- a/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts +++ b/src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts @@ -80,7 +80,7 @@ export class QqbotPluginPlatformController { packagePath?: string; }, ) { - return vbenSuccess(this.service.validateManifest(body)); + return vbenSuccess(this.service.uploadPackage(body)); } @Post('install') diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts deleted file mode 100644 index 6e8d418..0000000 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; -import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; -import { - buildQqbotFf14MarketCatalog, - buildQqbotFf14MarketCatalogFromTree, - isQqbotFf14LocationName, - parseQqbotFf14MarketPriceInput, - QQBOT_FF14_MARKET_DICT_CODES, - splitQqbotFf14WorldPath, - type QqbotFf14MarketCatalog, -} from '@/modules/qqbot/plugins/ff14-market/src'; -import { parseQqbotFflogsCharacterInput } from '@/modules/qqbot/plugins/fflogs/src'; -import type { QqbotPluginInputNormalizerPort } from '../../../application/argument/plugin-input-normalizer.port'; - -@Injectable() -export class QqbotPluginInputNormalizerService - implements QqbotPluginInputNormalizerPort -{ - constructor(private readonly dictService: DictService) {} - - async normalizeInput(input: QqbotPluginExecutionInput) { - const parserKey = `${input.context?.command?.parserKey || 'plain'}`.trim(); - const rawArgs = `${input.input?.raw ?? input.input?.text ?? ''}`.trim(); - if (parserKey === 'ff14Price') { - return { - ...input.input, - ...this.removeEmpty( - parseQqbotFf14MarketPriceInput( - rawArgs, - await this.getFf14MarketCatalog(), - ), - ), - }; - } - if (parserKey === 'fflogsCharacter') { - const catalog = await this.getFf14MarketCatalog(); - return { - ...input.input, - ...this.removeEmpty( - parseQqbotFflogsCharacterInput(rawArgs, { - resolveKnownWorld: (candidate) => { - if (!isQqbotFf14LocationName(catalog, candidate)) return null; - const worldPath = splitQqbotFf14WorldPath(candidate); - return { serverSlug: worldPath.world || candidate }; - }, - }), - ), - }; - } - return input.input; - } - - private async getFf14MarketCatalog(): Promise { - const treeCatalog = buildQqbotFf14MarketCatalogFromTree( - await this.dictService.relationTree({ - dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, - }), - ); - if (treeCatalog.dataCenters.length > 0) return treeCatalog; - - const [regions, dataCenters, worlds] = await Promise.all([ - this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), - this.dictService.getDictItemsByKey( - QQBOT_FF14_MARKET_DICT_CODES.dataCenter, - ), - this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), - ]); - return buildQqbotFf14MarketCatalog({ - dataCenters, - regions, - worlds, - }); - } - - private removeEmpty(input: Record) { - return Object.entries(input).reduce>( - (result, [key, value]) => { - if (value !== undefined && value !== '') result[key] = value; - return result; - }, - {}, - ); - } -} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts index ce678c1..b535cba 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts @@ -7,23 +7,49 @@ import { ToolsService } from '@/common'; import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service'; -import type { QqbotIntegrationPlugin } from '@/modules/qqbot/core/contract/qqbot.types'; +import type { + QqbotEventPluginDefinition, + QqbotIntegrationPlugin, + QqbotNormalizedMessage, +} from '@/modules/qqbot/core/contract/qqbot.types'; import { parseQqbotPluginManifest, type QqbotPluginManifest, } from '@/modules/qqbot/plugin-platform/domain/manifest'; import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk'; import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src'; -import type { BangDreamManifestOperation } from '@/modules/qqbot/plugins/bangdream/src'; +import { + buildFf14MarketCatalog, + buildFf14MarketCatalogFromTree, + isFf14LocationName, + QQBOT_FF14_MARKET_DICT_CODES, + splitFf14WorldPath, +} from '@/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds'; +import type { + BangDreamOperationHandlerName, + BangDreamOperationKey, +} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src'; import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; -export type QqbotRepeaterPluginPackage = ReturnType< +export type RepeaterPluginPackage = ReturnType< typeof createRepeaterPlugin >; +export type QqbotEventPluginPackage = { + bind(selfId: string): Promise | boolean; + getDefinition(): QqbotEventPluginDefinition; + getSummary(input: { + accountName?: string; + connectStatus?: string; + selfId: string; + }): Promise | unknown; + handleMessage(message: QqbotNormalizedMessage): Promise | boolean; + unbind(selfId: string): Promise | boolean; +}; + @Injectable() export class QqbotBuiltinPluginPackageLoaderService { private readonly logger = new Logger( @@ -47,7 +73,11 @@ export class QqbotBuiltinPluginPackageLoaderService { ]; } - loadRepeaterPlugin(): QqbotRepeaterPluginPackage { + loadEventPlugins(): QqbotEventPluginPackage[] { + return [this.loadRepeaterPlugin()]; + } + + loadRepeaterPlugin(): RepeaterPluginPackage { const manifest = this.loadManifest('repeater'); return createRepeaterPlugin({ host: { @@ -86,13 +116,15 @@ export class QqbotBuiltinPluginPackageLoaderService { normalizeError: (error) => this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'), operations: manifest.operations.map((operation) => ({ + aliases: operation.aliases, description: operation.description, - handlerName: operation.handlerName, + handlerName: operation.handlerName as BangDreamOperationHandlerName, inputSchema: operation.inputSchema, - key: operation.key, + key: operation.key as BangDreamOperationKey, name: operation.name, outputSchema: operation.outputSchema, - })) as BangDreamManifestOperation[], + timeoutMs: operation.timeoutMs, + })), pluginKey: manifest.pluginKey, version: manifest.version, }) as QqbotIntegrationPlugin; @@ -120,6 +152,7 @@ export class QqbotBuiltinPluginPackageLoaderService { host: { getConfig: (key) => this.configService.get(key), getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode), + resolveKnownWorld: (candidate) => this.resolveKnownFf14World(candidate), requestJson: (options) => this.httpClient.requestJson(options), }, manifest, @@ -171,6 +204,35 @@ export class QqbotBuiltinPluginPackageLoaderService { ); } + private async resolveKnownFf14World(candidate: string) { + const catalog = await this.loadFf14MarketCatalog(); + if (!isFf14LocationName(catalog, candidate)) return null; + const worldPath = splitFf14WorldPath(candidate); + return { serverSlug: worldPath.world || candidate }; + } + + private async loadFf14MarketCatalog() { + const treeCatalog = buildFf14MarketCatalogFromTree( + await this.dictService.relationTree({ + dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, + }), + ); + if (treeCatalog.dataCenters.length > 0) return treeCatalog; + + const [regions, dataCenters, worlds] = await Promise.all([ + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), + this.dictService.getDictItemsByKey( + QQBOT_FF14_MARKET_DICT_CODES.dataCenter, + ), + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), + ]); + return buildFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); + } + private resolvePluginRoot(pluginKey: string) { const sourceRoot = join( process.cwd(), diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-reader.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-reader.service.ts new file mode 100644 index 0000000..fcd1c9f --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-reader.service.ts @@ -0,0 +1,192 @@ +import { createHash } from 'crypto'; +import { existsSync, readFileSync, statSync } from 'fs'; +import { dirname, extname, isAbsolute, relative, resolve } from 'path'; +import { Injectable, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { throwVbenError } from '@/common'; +import { + parseQqbotPluginManifest, + type QqbotPluginManifest, +} from '@/modules/qqbot/plugin-platform/domain/manifest'; + +type PluginPackageBody = { + packageHash?: string; + packagePath?: string; +}; + +type PackedPluginFile = { + path: string; + sha256: string; +}; + +type PackedPluginPackage = { + contentHash?: unknown; + files?: unknown; + manifest?: unknown; +}; + +export type QqbotValidatedPluginPackage = { + manifest: QqbotPluginManifest; + packageHash: string; + packagePath: string; + packageSizeBytes: number; +}; + +const PACKAGE_EXTENSION = '.qqbot-plugin.json'; +const DEFAULT_MAX_PACKAGE_BYTES = 20 * 1024 * 1024; + +const sha256 = (content: Buffer | string) => + createHash('sha256').update(content).digest('hex'); + +const stableStringify = (value: unknown): string => { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +}; + +const isInsideDirectory = (parent: string, child: string) => { + const relativePath = relative(parent, child); + return ( + relativePath === '' || + (!!relativePath && + !relativePath.startsWith('..') && + !isAbsolute(relativePath)) + ); +}; + +@Injectable() +export class QqbotPluginPackageReaderService { + constructor( + @Optional() + private readonly configService?: ConfigService, + ) {} + + readPackage(body: PluginPackageBody): QqbotValidatedPluginPackage { + const packagePath = this.resolvePackagePath(body.packagePath); + const packageSizeBytes = statSync(packagePath).size; + const maxPackageBytes = this.getMaxPackageBytes(); + + if (packageSizeBytes > maxPackageBytes) { + throwVbenError('QQBot 插件包超过大小限制'); + } + + let packedPlugin: PackedPluginPackage; + try { + packedPlugin = JSON.parse(readFileSync(packagePath, 'utf8')); + } catch (error) { + throwVbenError('QQBot 插件包不是合法 JSON', undefined, error); + } + + const files = this.normalizePackageFiles(packedPlugin.files); + const contentHash = this.normalizeContentHash(packedPlugin.contentHash); + const expectedHash = sha256( + stableStringify({ + files, + manifest: packedPlugin.manifest, + }), + ); + + if (contentHash !== expectedHash) { + throwVbenError('QQBot 插件包 hash 校验失败'); + } + if (body.packageHash && body.packageHash !== expectedHash) { + throwVbenError('QQBot 插件包 hash 与请求不一致'); + } + + return { + manifest: parseQqbotPluginManifest(packedPlugin.manifest, { + pluginRoot: dirname(packagePath), + }), + packageHash: expectedHash, + packagePath, + packageSizeBytes, + }; + } + + private resolvePackagePath(packagePath?: string) { + if (!packagePath) throwVbenError('请选择插件包路径'); + + const resolvedPath = resolve(packagePath); + const controlledRoots = this.getControlledRoots(); + const allowed = controlledRoots.some((root) => + isInsideDirectory(root, resolvedPath), + ); + if (!allowed) { + throwVbenError('插件包路径不在受控目录内'); + } + if (!existsSync(resolvedPath)) { + throwVbenError('插件包文件不存在'); + } + if (!resolvedPath.endsWith(PACKAGE_EXTENSION)) { + throwVbenError('插件包文件扩展名不合法'); + } + if (!statSync(resolvedPath).isFile()) { + throwVbenError('插件包路径不是文件'); + } + if (extname(resolvedPath) !== '.json') { + throwVbenError('插件包文件扩展名不合法'); + } + + return resolvedPath; + } + + private getControlledRoots() { + const configuredRoots = [ + this.configService?.get('QQBOT_PLUGIN_PACKAGE_ROOT'), + this.configService?.get('QQBOT_PLUGIN_PACKAGE_ROOTS'), + ] + .filter((value): value is string => !!value) + .flatMap((value) => value.split(/[;,]/)) + .map((value) => value.trim()) + .filter(Boolean); + + const defaultRoots = [ + resolve(process.cwd(), '.kt-workspace', 'qqbot-plugin-packages'), + resolve(process.cwd(), 'src', 'modules', 'qqbot', 'plugins'), + ]; + + return [...configuredRoots, ...defaultRoots].map((root) => resolve(root)); + } + + private getMaxPackageBytes() { + const configured = Number( + this.configService?.get('QQBOT_PLUGIN_PACKAGE_MAX_BYTES'), + ); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_MAX_PACKAGE_BYTES; + } + + private normalizePackageFiles(files: unknown): PackedPluginFile[] { + if (!Array.isArray(files)) { + throwVbenError('QQBot 插件包文件清单不合法'); + } + return (files as unknown[]).map((file) => { + const record = file as Partial; + if (typeof record.path !== 'string' || typeof record.sha256 !== 'string') { + throwVbenError('QQBot 插件包文件清单不合法'); + } + return { + path: record.path, + sha256: record.sha256, + }; + }); + } + + private normalizeContentHash(contentHash: unknown) { + if (typeof contentHash !== 'string' || !contentHash) { + throwVbenError('QQBot 插件包缺少 contentHash'); + } + return contentHash; + } +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts new file mode 100644 index 0000000..63fe024 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker-runtime.factory.ts @@ -0,0 +1,168 @@ +import { Injectable } from '@nestjs/common'; +import { throwVbenError } from '@/common'; +import { + QqbotBuiltinPluginPackageLoaderService, + type QqbotEventPluginPackage, +} from '../package/builtin-plugin-package-loader.service'; +import type { + QqbotIntegrationPlugin, + QqbotPluginOperation, + QqbotNormalizedMessage, +} from '@/modules/qqbot/core/contract/qqbot.types'; +import type { + QqbotPluginRuntimeFactory, +} from '@/modules/qqbot/plugin-platform/application/plugin-platform.service'; +import type { + QqbotPluginInstallation, + QqbotPluginVersion, +} from '@/modules/qqbot/plugin-platform/infrastructure/persistence'; +import { + QqbotPluginWorkerRuntime, +} from './worker-runtime'; +import type { + QqbotPluginWorkerDriver, + QqbotPluginWorkerRequest, +} from './worker-runtime.types'; + +type RuntimeCommandPlugin = QqbotIntegrationPlugin & { + activate?: () => Promise | unknown; + dispose?: () => Promise | unknown; +}; + +@Injectable() +export class QqbotBuiltinPluginWorkerRuntimeFactoryService + implements QqbotPluginRuntimeFactory +{ + constructor( + private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService, + ) {} + + create( + installation: QqbotPluginInstallation, + version: QqbotPluginVersion, + ) { + const pluginKey = getManifestPluginKey(version.manifestJson); + return new QqbotPluginWorkerRuntime( + new QqbotBuiltinPluginWorkerDriver(this.pluginLoader, pluginKey), + { + defaultTimeoutMs: getDefaultRuntimeTimeout(version.manifestJson), + installationId: installation.id, + pluginKey, + }, + ); + } +} + +class QqbotBuiltinPluginWorkerDriver implements QqbotPluginWorkerDriver { + private commandPlugin?: RuntimeCommandPlugin; + private eventPlugin?: QqbotEventPluginPackage; + + constructor( + private readonly pluginLoader: QqbotBuiltinPluginPackageLoaderService, + private readonly pluginKey: string, + ) {} + + async request(message: QqbotPluginWorkerRequest): Promise { + switch (message.type) { + case 'load': + return this.load(); + case 'activate': + await this.commandPlugin?.activate?.(); + return { ok: true }; + case 'health': + return this.health(); + case 'executeOperation': + return this.executeOperation(message); + case 'handleEvent': + return this.handleEvent(message); + case 'deactivate': + return { ok: true }; + case 'dispose': + await this.commandPlugin?.dispose?.(); + return { ok: true }; + default: + throwVbenError(`未知插件运行时请求:${message.type}`); + } + } + + async dispose(): Promise { + await this.commandPlugin?.dispose?.(); + } + + private load() { + this.commandPlugin = this.pluginLoader + .loadCommandPlugins() + .find((plugin) => plugin.key === this.pluginKey) as + | RuntimeCommandPlugin + | undefined; + this.eventPlugin = this.pluginLoader + .loadEventPlugins() + .find((plugin) => plugin.getDefinition().key === this.pluginKey); + + if (!this.commandPlugin && !this.eventPlugin) { + throwVbenError(`QQBot 插件运行时不存在:${this.pluginKey}`); + } + + return { + ok: true, + pluginKey: this.pluginKey, + triggerMode: this.commandPlugin ? 'command' : 'event', + }; + } + + private async health() { + if (this.commandPlugin?.healthCheck) { + return this.commandPlugin.healthCheck(); + } + if (this.eventPlugin) { + return { + message: this.eventPlugin.getDefinition().remark || '事件插件可用', + status: 'healthy', + }; + } + throwVbenError(`QQBot 插件运行时未加载:${this.pluginKey}`); + } + + private async executeOperation(message: QqbotPluginWorkerRequest) { + const operation = this.commandPlugin?.operations.find( + (item: QqbotPluginOperation) => item.key === message.operationKey, + ); + if (!operation) { + throwVbenError(`QQBot 插件能力不存在:${message.operationKey}`); + } + return operation.execute( + (message.input || {}) as Record, + {}, + ); + } + + private async handleEvent(message: QqbotPluginWorkerRequest) { + if (!this.eventPlugin) { + return false; + } + const definition = this.eventPlugin.getDefinition(); + if (message.eventKey && message.eventKey !== definition.key) return false; + if (definition.triggerType !== 'message') return false; + return this.eventPlugin.handleMessage( + (message.event || {}) as QqbotNormalizedMessage, + ); + } +} + +function getManifestPluginKey(manifest: unknown) { + const pluginKey = + typeof manifest === 'object' && manifest + ? (manifest as { pluginKey?: unknown }).pluginKey + : null; + if (typeof pluginKey === 'string' && pluginKey) return pluginKey; + throwVbenError('插件 manifest 缺少 pluginKey,无法创建运行时'); +} + +function getDefaultRuntimeTimeout(manifest: unknown) { + const runtime = + typeof manifest === 'object' && manifest + ? (manifest as { runtime?: { timeoutMs?: unknown } }).runtime + : null; + const timeoutMs = Number(runtime?.timeoutMs || 30_000); + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30_000; +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/index.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/index.ts index 04ee5eb..e3c7ec8 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/index.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/index.ts @@ -1,2 +1,3 @@ export * from './worker-runtime'; export * from './worker-runtime.types'; +export * from './builtin-plugin-worker-runtime.factory'; diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts index e9a95c7..d313999 100644 --- a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts +++ b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts @@ -6,7 +6,6 @@ import { DictModule } from '@/modules/admin/platform-config/dict/dict.module'; import { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service'; -import { QQBOT_PLUGIN_INPUT_NORMALIZER } from './application/argument/plugin-input-normalizer.port'; import { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter'; @@ -14,9 +13,11 @@ import { QqbotPluginPlatformService } from './application/plugin-platform.servic import { QqbotPluginPlatformController } from './contract/plugin-platform.controller'; import { QqbotPluginController } from './contract/qqbot-plugin.controller'; import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence'; -import { QqbotPluginInputNormalizerService } from './infrastructure/integration/argument/plugin-input-normalizer.service'; import { QqbotBuiltinPluginPackageLoaderService } from './infrastructure/integration/package/builtin-plugin-package-loader.service'; +import { QqbotPluginPackageReaderService } from './infrastructure/integration/package/plugin-package-reader.service'; import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk'; +import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from './infrastructure/integration/runtime'; +import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service'; @Module({ controllers: [QqbotPluginController, QqbotPluginPlatformController], @@ -36,16 +37,17 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk'; QqbotEventPluginRegistryService, QqbotPluginArgumentParserService, QqbotPluginExecutionAdapter, - QqbotPluginInputNormalizerService, QqbotBuiltinPluginPackageLoaderService, - { - provide: QQBOT_PLUGIN_INPUT_NORMALIZER, - useExisting: QqbotPluginInputNormalizerService, - }, + QqbotBuiltinPluginWorkerRuntimeFactoryService, + QqbotPluginPackageReaderService, { provide: QQBOT_PLUGIN_EXECUTION_PORT, useExisting: QqbotPluginExecutionAdapter, }, + { + provide: QQBOT_PLUGIN_RUNTIME_FACTORY, + useExisting: QqbotBuiltinPluginWorkerRuntimeFactoryService, + }, QqbotPluginHttpClientService, QqbotPluginPlatformService, QqbotPluginRegistryService, diff --git a/src/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context.ts b/src/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context.ts index ad54887..5e704ca 100644 --- a/src/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context.ts +++ b/src/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context.ts @@ -12,14 +12,14 @@ import { fuzzySearch, type FuzzySearchResult, } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search'; -import mainAPI, { - waitForMainDataReady, -} from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache, { + waitForBangDreamCatalogReady, +} from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import type { - QqbotBangDreamCommandInput, - QqbotBangDreamCommandOutput, - QqbotBangDreamOperationKey, -} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types'; + BangDreamCommandInput, + BangDreamCommandOutput, + BangDreamOperationKey, +} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; const SOURCE_NAME = 'BangDream 内置插件'; @@ -58,8 +58,8 @@ export class BangDreamCommandContext { } async checkHealth() { - await waitForMainDataReady(); - const data = mainAPI as { cards?: unknown; songs?: unknown }; + await waitForBangDreamCatalogReady(); + const data = bangdreamCatalogCache as { cards?: unknown; songs?: unknown }; if (!data.songs || !data.cards) { throw new Error('BangDream 数据配置未加载'); } @@ -79,10 +79,10 @@ export class BangDreamCommandContext { } toImageReply( - operationKey: QqbotBangDreamOperationKey, + operationKey: BangDreamOperationKey, query: string, list: Array, - ): QqbotBangDreamCommandOutput { + ): BangDreamCommandOutput { const images = list.filter((item): item is Buffer => Buffer.isBuffer(item)); if (images.length === 0) { const message = @@ -102,7 +102,7 @@ export class BangDreamCommandContext { } getRenderOptions( - input: QqbotBangDreamCommandInput, + input: BangDreamCommandInput, defaults: { useEasyBG?: boolean } = {}, ) { return { @@ -125,7 +125,7 @@ export class BangDreamCommandContext { }; } - pickDisplayedServerList(input: QqbotBangDreamCommandInput) { + pickDisplayedServerList(input: BangDreamCommandInput) { const source = input.displayedServerList || this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.displayedServers); @@ -138,7 +138,7 @@ export class BangDreamCommandContext { return servers.length > 0 ? [...new Set(servers)] : defaultServers; } - pickMainServer(input: QqbotBangDreamCommandInput, tokens: string[]): Server { + pickMainServer(input: BangDreamCommandInput, tokens: string[]): Server { const explicit = this.firstDefined( input.mainServer, input.serverName, @@ -171,24 +171,24 @@ export class BangDreamCommandContext { return server === undefined ? undefined : (server as Server); } - requireText(input: QqbotBangDreamCommandInput, message: string) { + requireText(input: BangDreamCommandInput, message: string) { const text = this.pickText(input); if (!text) throw new Error(message); return text; } - pickText(input: QqbotBangDreamCommandInput) { + pickText(input: BangDreamCommandInput) { return `${input.query || input.text || input.raw || ''}`.trim(); } - getTokens(input: QqbotBangDreamCommandInput) { + getTokens(input: BangDreamCommandInput) { if (Array.isArray(input.args)) { return input.args.map((item) => `${item}`.trim()).filter(Boolean); } return this.pickText(input).split(/\s+/).filter(Boolean); } - firstToken(input: QqbotBangDreamCommandInput) { + firstToken(input: BangDreamCommandInput) { return this.getTokens(input)[0]; } diff --git a/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts new file mode 100644 index 0000000..aec0230 --- /dev/null +++ b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts @@ -0,0 +1,138 @@ +import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider'; +import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; +import { + BANGDREAM_TSUGU_ENV_KEYS, + normalizeBangDreamPositiveInteger, +} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options'; +import { + readBangDreamRuntimeConfig, + sleepBangDreamRuntime, +} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; + +const bangdreamCatalogCache: Record = {}; +const REQUIRED_CATALOG_KEYS = [ + 'cards', + 'characters', + 'events', + 'gacha', + 'songs', +]; +const DEFAULT_CATALOG_READY_TIMEOUT_MS = 15000; + +function getCatalogReadyTimeoutMs(): number { + return normalizeBangDreamPositiveInteger( + readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs), + DEFAULT_CATALOG_READY_TIMEOUT_MS, + ); +} + +function isCatalogReady(): boolean { + return REQUIRED_CATALOG_KEYS.every((key) => { + const collection = bangdreamCatalogCache[key]; + return collection && Object.keys(collection).length > 0; + }); +} + +async function rejectAfter(ms: number): Promise { + await sleepBangDreamRuntime(ms); + throw new Error(`BangDream 主数据首次加载超时:${ms}ms`); +} + +/** + * 加载 BangDream 领域目录数据。 + * + * @param useCache - use缓存参数,未传入时使用默认值。 + */ +async function loadCatalogData(useCache: boolean = false) { + logger('catalog', 'loading catalog...'); + const promiseAll = Object.keys(bestdoriApiPath).map(async (key) => { + if (useCache) { + return (bangdreamCatalogCache[key] = + await bangdreamBestdoriProvider.getJson(bestdoriApiPath[key], { + cacheTime: 1 / 0, + })); + } else { + try { + return (bangdreamCatalogCache[key] = + await bangdreamBestdoriProvider.getJson(bestdoriApiPath[key])); + } catch { + logger('catalog', `load ${key} failed`); + } + } + }); + + await Promise.all(promiseAll); + + const cardsCnFix = + await bangdreamStaticPatchProvider.readJson>( + 'cards-cn-fix.json', + ); + for (const key in cardsCnFix) { + bangdreamCatalogCache['cards'][key] = cardsCnFix[key]; + } + const skillsCnFix = + await bangdreamStaticPatchProvider.readJson>( + 'skills-cn-fix.json', + ); + for (const key in skillsCnFix) { + bangdreamCatalogCache['skills'][key] = skillsCnFix[key]; + } + const areaItemFix = + await bangdreamStaticPatchProvider.readJson>( + 'area-item-fix.json', + ); + for (const key in areaItemFix) { + if (bangdreamCatalogCache['areaItems'][key] == undefined) { + bangdreamCatalogCache['areaItems'][key] = areaItemFix[key]; + } + } + try { + const songNickname = await bangdreamStaticPatchProvider.readExcelRows<{ + Id: number; + Nickname: string; + }>('nickname-song.xlsx'); + for (let i = 0; i < songNickname.length; i++) { + const element = songNickname[i]; + if (bangdreamCatalogCache['songs'][element['Id'].toString()]) { + bangdreamCatalogCache['songs'][element['Id'].toString()]['nickname'] = + element['Nickname']; + } + } + } catch { + logger('catalog', '读取 nickname-song.xlsx 失败'); + } + logger('catalog', 'catalog loaded'); +} + +let initialLoadPromise: Promise | undefined; + +function ensureCatalogInitialLoad() { + if (!initialLoadPromise) { + logger('catalog', 'initializing...'); + initialLoadPromise = loadCatalogData(true).then(async () => { + logger('catalog', 'initializing done'); + await loadCatalogData(); + }); + } + return initialLoadPromise; +} + +/** + * 等待 BangDream 目录数据完成首次加载。 + */ +export async function waitForBangDreamCatalogReady(): Promise { + if (isCatalogReady()) { + return; + } + await Promise.race([ + ensureCatalogInitialLoad(), + rejectAfter(getCatalogReadyTimeoutMs()), + ]); + if (!isCatalogReady()) { + throw new Error('BangDream 主数据未完成关键集合加载'); + } +} + +export default bangdreamCatalogCache; diff --git a/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository.ts b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository.ts new file mode 100644 index 0000000..206065e --- /dev/null +++ b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository.ts @@ -0,0 +1,58 @@ +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; +import type { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'; + +export type BangDreamCatalogKey = keyof typeof BANGDREAM_BESTDORI_API_PATHS; +export type BangDreamCatalogCollection = Record; + +export class BangDreamCatalogRepository { + constructor( + private readonly catalog: Record = + bangdreamCatalogCache as Record, + ) {} + + /** + * 获取 Bestdori 目录集合。 + * + * @param key - 目录集合编码。 + */ + getCollection( + key: BangDreamCatalogKey, + ): BangDreamCatalogCollection { + return (this.catalog[key] ?? {}) as BangDreamCatalogCollection; + } + + /** + * 获取原始目录值,适用于 rates 这类非对象映射集合。 + * + * @param key - 目录集合编码。 + */ + getValue(key: BangDreamCatalogKey): T { + return (this.catalog[key] ?? {}) as T; + } + + /** + * 按 ID 获取 Bestdori 目录实体。 + * + * @param key - 目录集合编码。 + * @param id - 实体 ID。 + */ + getEntity( + key: BangDreamCatalogKey, + id: number | string, + ): T | undefined { + return this.getCollection(key)[String(id)]; + } + + /** + * 获取集合中可转成数字的实体 ID。 + * + * @param key - 目录集合编码。 + */ + getNumericIds(key: BangDreamCatalogKey): number[] { + return Object.keys(this.getCollection(key)) + .map(Number) + .filter((id) => Number.isFinite(id)); + } +} + +export const bangdreamCatalogRepository = new BangDreamCatalogRepository(); diff --git a/src/modules/qqbot/plugins/bangdream/src/application/execution/operation-lifecycle.ts b/src/modules/qqbot/plugins/bangdream/src/application/execution/operation-lifecycle.ts new file mode 100644 index 0000000..ceed12b --- /dev/null +++ b/src/modules/qqbot/plugins/bangdream/src/application/execution/operation-lifecycle.ts @@ -0,0 +1,164 @@ +import type { + BangDreamCommandInput, + BangDreamOperationKey, +} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; +import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; + +export type BangDreamExecutionStage = + | 'handler' + | 'catalog' + | 'operation' + | 'output' + | 'start'; + +export type BangDreamOperationLifecycleContext = { + handlerName?: string; + imageCount?: number; + input: BangDreamCommandInput; + operationKey: BangDreamOperationKey; + query?: string; + stage: BangDreamExecutionStage; + startedAt: number; +}; + +export type BangDreamOperationLifecycleObserver = { + afterOutput?: ( + context: BangDreamOperationLifecycleContext, + ) => Promise | void; + afterResolve?: ( + context: BangDreamOperationLifecycleContext, + ) => Promise | void; + beforeParse?: ( + context: BangDreamOperationLifecycleContext, + ) => Promise | void; + beforeRender?: ( + context: BangDreamOperationLifecycleContext, + ) => Promise | void; + name: string; + onError?: ( + context: BangDreamOperationLifecycleContext, + error: unknown, + ) => Promise | void; + order?: number; +}; + +type BangDreamLifecycleObserverMethod = + | 'afterOutput' + | 'afterResolve' + | 'beforeParse' + | 'beforeRender'; + +export class BangDreamOperationLifecycle { + private readonly observers: BangDreamOperationLifecycleObserver[]; + + constructor(observers: readonly BangDreamOperationLifecycleObserver[] = []) { + this.observers = [...observers].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ); + } + + async beforeParse(context: BangDreamOperationLifecycleContext) { + await this.emit('beforeParse', context); + } + + async afterResolve(context: BangDreamOperationLifecycleContext) { + await this.emit('afterResolve', context); + } + + async beforeRender(context: BangDreamOperationLifecycleContext) { + await this.emit('beforeRender', context); + } + + async afterOutput(context: BangDreamOperationLifecycleContext) { + await this.emit('afterOutput', context); + } + + async onError( + context: BangDreamOperationLifecycleContext, + error: unknown, + ) { + for (const observer of this.observers) { + await observer.onError?.(context, error); + } + } + + private async emit( + method: BangDreamLifecycleObserverMethod, + context: BangDreamOperationLifecycleContext, + ) { + for (const observer of this.observers) { + const handler = observer[method]; + if (typeof handler === 'function') { + await handler(context); + } + } + } +} + +export function createBangDreamOperationLifecycleContext( + operationKey: BangDreamOperationKey, + input: BangDreamCommandInput, +): BangDreamOperationLifecycleContext { + return { + input, + operationKey, + query: extractBangDreamInputText(input), + stage: 'start', + startedAt: Date.now(), + }; +} + +export function createBangDreamOperationLogObserver(): BangDreamOperationLifecycleObserver { + return { + afterOutput: (context) => { + logger( + 'operation', + formatBangDreamOperationLifecycleObserverMessage('success', context), + ); + }, + beforeParse: (context) => { + logger( + 'operation', + formatBangDreamOperationLifecycleObserverMessage('start', context), + ); + }, + name: 'BangDreamOperationLogObserver', + onError: (context, error) => { + logger( + 'operation', + `${formatBangDreamOperationLifecycleObserverMessage('error', context)} error=${getOperationLifecycleErrorMessage(error)}`, + ); + }, + }; +} + +function formatBangDreamOperationLifecycleObserverMessage( + status: 'error' | 'start' | 'success', + context: BangDreamOperationLifecycleContext, +) { + const durationMs = Date.now() - context.startedAt; + return [ + `status=${status}`, + `operation=${context.operationKey}`, + `stage=${context.stage}`, + context.handlerName ? `handler=${context.handlerName}` : '', + context.query ? `query=${context.query}` : '', + context.imageCount === undefined ? '' : `imageCount=${context.imageCount}`, + `durationMs=${durationMs}`, + ] + .filter(Boolean) + .join(' '); +} + +function extractBangDreamInputText(input: BangDreamCommandInput) { + const direct = `${input.query || input.text || input.raw || ''}`.trim(); + if (direct) return direct; + return Array.isArray(input.args) ? input.args.join(' ').trim() : ''; +} + +function getOperationLifecycleErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error === undefined || error === null) return ''; + return `${error}`; +} diff --git a/src/modules/qqbot/plugins/bangdream/src/application/hook/hook-registry.ts b/src/modules/qqbot/plugins/bangdream/src/application/hook/hook-registry.ts deleted file mode 100644 index a99d039..0000000 --- a/src/modules/qqbot/plugins/bangdream/src/application/hook/hook-registry.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - QqbotBangDreamCommandInput, - QqbotBangDreamOperationKey, -} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types'; -import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; - -export type BangDreamExecutionStage = - | 'handler' - | 'mainData' - | 'operation' - | 'output' - | 'start'; - -export type BangDreamHookContext = { - handlerName?: string; - imageCount?: number; - input: QqbotBangDreamCommandInput; - operationKey: QqbotBangDreamOperationKey; - query?: string; - stage: BangDreamExecutionStage; - startedAt: number; -}; - -export type BangDreamHook = { - afterOutput?: (context: BangDreamHookContext) => Promise | void; - afterResolve?: (context: BangDreamHookContext) => Promise | void; - beforeParse?: (context: BangDreamHookContext) => Promise | void; - beforeRender?: (context: BangDreamHookContext) => Promise | void; - name: string; - onError?: ( - context: BangDreamHookContext, - error: unknown, - ) => Promise | void; - order?: number; -}; - -type BangDreamSimpleHookMethod = - | 'afterOutput' - | 'afterResolve' - | 'beforeParse' - | 'beforeRender'; - -export class BangDreamHookRegistry { - private readonly hooks: BangDreamHook[]; - - constructor(hooks: readonly BangDreamHook[] = []) { - this.hooks = [...hooks].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); - } - - async beforeParse(context: BangDreamHookContext) { - await this.emit('beforeParse', context); - } - - async afterResolve(context: BangDreamHookContext) { - await this.emit('afterResolve', context); - } - - async beforeRender(context: BangDreamHookContext) { - await this.emit('beforeRender', context); - } - - async afterOutput(context: BangDreamHookContext) { - await this.emit('afterOutput', context); - } - - async onError(context: BangDreamHookContext, error: unknown) { - for (const hook of this.hooks) { - await hook.onError?.(context, error); - } - } - - private async emit( - method: BangDreamSimpleHookMethod, - context: BangDreamHookContext, - ) { - for (const hook of this.hooks) { - const handler = hook[method]; - if (typeof handler === 'function') { - await handler(context); - } - } - } -} - -export function createBangDreamHookContext( - operationKey: QqbotBangDreamOperationKey, - input: QqbotBangDreamCommandInput, -): BangDreamHookContext { - return { - input, - operationKey, - query: extractBangDreamInputText(input), - stage: 'start', - startedAt: Date.now(), - }; -} - -export function createBangDreamLogHook(): BangDreamHook { - return { - afterOutput: (context) => { - logger('operation', formatBangDreamHookMessage('success', context)); - }, - beforeParse: (context) => { - logger('operation', formatBangDreamHookMessage('start', context)); - }, - name: 'BangDreamLogHook', - onError: (context, error) => { - logger( - 'operation', - `${formatBangDreamHookMessage('error', context)} error=${getHookErrorMessage(error)}`, - ); - }, - }; -} - -function formatBangDreamHookMessage( - status: 'error' | 'start' | 'success', - context: BangDreamHookContext, -) { - const durationMs = Date.now() - context.startedAt; - return [ - `status=${status}`, - `operation=${context.operationKey}`, - `stage=${context.stage}`, - context.handlerName ? `handler=${context.handlerName}` : '', - context.query ? `query=${context.query}` : '', - context.imageCount === undefined ? '' : `imageCount=${context.imageCount}`, - `durationMs=${durationMs}`, - ] - .filter(Boolean) - .join(' '); -} - -function extractBangDreamInputText(input: QqbotBangDreamCommandInput) { - const direct = `${input.query || input.text || input.raw || ''}`.trim(); - if (direct) return direct; - return Array.isArray(input.args) ? input.args.join(' ').trim() : ''; -} - -function getHookErrorMessage(error: unknown) { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - if (error === undefined || error === null) return ''; - return `${error}`; -} diff --git a/src/modules/qqbot/plugins/bangdream/src/application/main-data-store.ts b/src/modules/qqbot/plugins/bangdream/src/application/main-data-store.ts deleted file mode 100644 index 533d6dd..0000000 --- a/src/modules/qqbot/plugins/bangdream/src/application/main-data-store.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; -import { bangDreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider'; -import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; -import { - BANGDREAM_TSUGU_ENV_KEYS, - normalizeBangDreamPositiveInteger, -} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options'; -import { - readBangDreamRuntimeConfig, - sleepBangDreamRuntime, -} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; - -const mainAPI: Record = {}; //main对象,用于存放所有api数据,数据来源于Bestdori网站 -const REQUIRED_MAIN_DATA_KEYS = [ - 'cards', - 'characters', - 'events', - 'gacha', - 'songs', -]; -const DEFAULT_MAIN_DATA_READY_TIMEOUT_MS = 15000; - -function getMainDataReadyTimeoutMs(): number { - return normalizeBangDreamPositiveInteger( - readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs), - DEFAULT_MAIN_DATA_READY_TIMEOUT_MS, - ); -} - -function isMainDataReady(): boolean { - return REQUIRED_MAIN_DATA_KEYS.every((key) => { - const collection = mainAPI[key]; - return collection && Object.keys(collection).length > 0; - }); -} - -async function rejectAfter(ms: number): Promise { - await sleepBangDreamRuntime(ms); - throw new Error(`BangDream 主数据首次加载超时:${ms}ms`); -} - -//加载mainAPI -/** - * 在BangDream 领域模型层中加载主数据API。 - * - * @param useCache - use缓存参数,未传入时使用默认值。 - */ -async function loadMainAPI(useCache: boolean = false) { - logger('mainAPI', 'loading mainAPI...'); - const promiseAll = Object.keys(bestdoriApiPath).map(async (key) => { - if (useCache) { - return (mainAPI[key] = await bangDreamBestdoriProvider.getJson( - bestdoriApiPath[key], - { cacheTime: 1 / 0 }, - )); - } else { - try { - return (mainAPI[key] = await bangDreamBestdoriProvider.getJson( - bestdoriApiPath[key], - )); - } catch { - logger('mainAPI', `load ${key} failed`); - } - } - }); - - await Promise.all(promiseAll); - - const cardsCnFix = - await bangDreamStaticPatchProvider.readJson>( - 'cards-cn-fix.json', - ); - for (const key in cardsCnFix) { - mainAPI['cards'][key] = cardsCnFix[key]; - } - const skillsCnFix = - await bangDreamStaticPatchProvider.readJson>( - 'skills-cn-fix.json', - ); - for (const key in skillsCnFix) { - mainAPI['skills'][key] = skillsCnFix[key]; - } - const areaItemFix = - await bangDreamStaticPatchProvider.readJson>( - 'area-item-fix.json', - ); - for (const key in areaItemFix) { - if (mainAPI['areaItems'][key] == undefined) { - mainAPI['areaItems'][key] = areaItemFix[key]; - } - } - try { - const songNickname = await bangDreamStaticPatchProvider.readExcelRows<{ - Id: number; - Nickname: string; - }>('nickname-song.xlsx'); - for (let i = 0; i < songNickname.length; i++) { - const element = songNickname[i]; - if (mainAPI['songs'][element['Id'].toString()]) { - mainAPI['songs'][element['Id'].toString()]['nickname'] = - element['Nickname']; - } - } - } catch { - logger('mainAPI', '读取 nickname-song.xlsx 失败'); - } - logger('mainAPI', 'mainAPI loaded'); -} - -let initialLoadPromise: Promise | undefined; - -function ensureMainDataInitialLoad() { - if (!initialLoadPromise) { - logger('mainAPI', 'initializing...'); - initialLoadPromise = loadMainAPI(true).then(async () => { - logger('mainAPI', 'initializing done'); - await loadMainAPI(); - }); - } - return initialLoadPromise; -} - -/** - * 等待 BangDream 主数据完成首次加载。 - */ -export async function waitForMainDataReady(): Promise { - if (isMainDataReady()) { - return; - } - await Promise.race([ - ensureMainDataInitialLoad(), - rejectAfter(getMainDataReadyTimeoutMs()), - ]); - if (!isMainDataReady()) { - throw new Error('BangDream 主数据未完成关键集合加载'); - } -} - -export default mainAPI; diff --git a/src/modules/qqbot/plugins/bangdream/src/application/main-data.repository.ts b/src/modules/qqbot/plugins/bangdream/src/application/main-data.repository.ts deleted file mode 100644 index ae0f884..0000000 --- a/src/modules/qqbot/plugins/bangdream/src/application/main-data.repository.ts +++ /dev/null @@ -1,60 +0,0 @@ -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; -import type { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'; - -export type BangDreamMainDataKey = keyof typeof BANGDREAM_BESTDORI_API_PATHS; -export type BangDreamMainDataCollection = Record; - -export class BangDreamMainDataRepository { - constructor( - private readonly store: Record = mainAPI as Record< - string, - unknown - >, - ) {} - - /** - * 获取 Bestdori 主数据集合。 - * - * @param key - 主数据集合编码。 - */ - getCollection( - key: BangDreamMainDataKey, - ): BangDreamMainDataCollection { - return (this.store[key] ?? {}) as BangDreamMainDataCollection; - } - - /** - * 获取原始主数据值,适用于 rates 这类非对象映射集合。 - * - * @param key - 主数据集合编码。 - */ - getValue(key: BangDreamMainDataKey): T { - return (this.store[key] ?? {}) as T; - } - - /** - * 按 ID 获取 Bestdori 主数据实体。 - * - * @param key - 主数据集合编码。 - * @param id - 实体 ID。 - */ - getEntity( - key: BangDreamMainDataKey, - id: number | string, - ): T | undefined { - return this.getCollection(key)[String(id)]; - } - - /** - * 获取集合中可转成数字的实体 ID。 - * - * @param key - 主数据集合编码。 - */ - getNumericIds(key: BangDreamMainDataKey): number[] { - return Object.keys(this.getCollection(key)) - .map(Number) - .filter((id) => Number.isFinite(id)); - } -} - -export const bangDreamMainDataRepository = new BangDreamMainDataRepository(); diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.repository.ts index 8dcdca2..e4cb2b0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-art.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import type { BangDreamCardArtAttribute } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-art.layout'; import { @@ -8,7 +8,7 @@ import { export class CardArtResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-resource.repository.ts index da19756..cc3d188 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card-resource.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { @@ -35,7 +35,7 @@ function toServerCode(server: Server | undefined): string { export class CardResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card.model.ts index 34ca663..93fb1bd 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card.model.ts @@ -8,7 +8,7 @@ import { import { Gacha } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model'; import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { Image, loadImage } from 'skia-canvas'; -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { BANGDREAM_CARD_TYPE_NAME } from '@/modules/qqbot/plugins/bangdream/src/config/dictionary/default-dictionary'; @@ -94,7 +94,7 @@ export class Card { */ constructor(cardId: number) { this.cardId = cardId; - const cardData = bangDreamMainDataRepository.getEntity>( + const cardData = bangdreamCatalogRepository.getEntity>( 'cards', cardId, ); diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/card/card.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/card/card.repository.ts index d98eb6c..2030beb 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/card/card.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/card/card.repository.ts @@ -1,15 +1,15 @@ import { Card } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model'; import { - bangDreamMainDataRepository, - type BangDreamMainDataCollection, -} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; + bangdreamCatalogRepository, + type BangDreamCatalogCollection, +} from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; export class CardRepository { /** * 获取卡牌主数据集合。 */ - getSource(): BangDreamMainDataCollection { - return bangDreamMainDataRepository.getCollection('cards'); + getSource(): BangDreamCatalogCollection { + return bangdreamCatalogRepository.getCollection('cards'); } /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/area-item.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/area-item.model.ts index 5ceaa29..bb1b313 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/area-item.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/area-item.model.ts @@ -1,4 +1,4 @@ -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Card, Stat } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model'; @@ -21,7 +21,7 @@ export class AreaItem { */ constructor(areaItemId: number) { this.areaItemId = areaItemId; - const areaItemData = mainAPI['areaItems'][areaItemId.toString()]; + const areaItemData = bangdreamCatalogCache['areaItems'][areaItemId.toString()]; if (areaItemData == undefined) { this.isExist = false; return; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute-resource.repository.ts index 0c2c5d0..84a46e4 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute-resource.repository.ts @@ -1,9 +1,9 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; export class AttributeResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band-resource.repository.ts index 275a3bc..3a4a6af 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band-resource.repository.ts @@ -1,10 +1,10 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; export class BandResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model.ts index f4629fb..aea4078 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model.ts @@ -1,4 +1,4 @@ -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; import { Image, loadImage } from 'skia-canvas'; import { convertSvgToPngBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; @@ -18,8 +18,8 @@ export class Band { */ constructor(bandId: number) { this.bandId = bandId; - const bandData = mainAPI['singer'][bandId.toString()]; - if (mainAPI['bands'][bandId.toString()] != undefined) { + const bandData = bangdreamCatalogCache['singer'][bandId.toString()]; + if (bangdreamCatalogCache['bands'][bandId.toString()] != undefined) { this.hasIcon = true; } if (bandData == undefined) { @@ -36,7 +36,7 @@ export class Band { */ getMembers() { const members = []; - const characterList = mainAPI['characters']; + const characterList = bangdreamCatalogCache['characters']; for (const characterID in characterList) { const character = new Character(parseInt(characterID)); if (character.bandId == this.bandId) { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume-resource.repository.ts index af97e78..c1b2bc0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume-resource.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { getServerByPriority, @@ -17,7 +17,7 @@ function toServerCode(server: Server | undefined): string { export class CostumeResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume.model.ts index b21959a..4d3c6a0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/costume.model.ts @@ -1,5 +1,5 @@ import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Image, loadImage } from 'skia-canvas'; import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; @@ -23,7 +23,7 @@ export class Costume { */ constructor(costumeId: number) { this.costumeId = costumeId; - const costumeData = mainAPI['costumes'][costumeId.toString()]; + const costumeData = bangdreamCatalogCache['costumes'][costumeId.toString()]; if (costumeData == undefined) { this.isExist = false; return; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository.ts index dc710d3..f58572c 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; @@ -11,7 +11,7 @@ const LEGACY_ANIMATED_TEXTURE_NAMES = new Set([ export class DegreeResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree.model.ts index 7f80349..708dbe6 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/degree.model.ts @@ -1,6 +1,6 @@ import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Canvas, Image, loadImage } from 'skia-canvas'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { readJSONFromBuffer } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { degreeResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository'; @@ -20,7 +20,7 @@ export class Degree { */ constructor(degreeId) { this.degreeId = degreeId; - const degreeData = mainAPI['degrees'][degreeId.toString()]; + const degreeData = bangdreamCatalogCache['degrees'][degreeId.toString()]; if (degreeData == undefined) { this.isExist = false; return; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository.ts index b5f9d0d..04f82f2 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; @@ -14,7 +14,7 @@ function toServerCode(server: Server | undefined): string { export class ItemResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item.model.ts index d6b42ca..e3a4e8e 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/item.model.ts @@ -3,7 +3,7 @@ import { Server, getServerByPriority, } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { itemResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository'; import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; import { BANGDREAM_ITEM_TYPE_PREFIXES } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'; @@ -47,7 +47,7 @@ export class Item { return; } //如果是其他物品 - const itemData = mainAPI['items'][itemId]; + const itemData = bangdreamCatalogCache['items'][itemId]; if (itemData == undefined) { return; } diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/server-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/server-resource.repository.ts index c028170..378259e 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/server-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/server-resource.repository.ts @@ -1,10 +1,10 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest'; export class ServerResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/skill.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/skill.model.ts index 09eea38..da555f1 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/catalog/skill.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/catalog/skill.model.ts @@ -1,4 +1,4 @@ -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; export class Skill { skillId: number; @@ -18,13 +18,13 @@ export class Skill { */ constructor(skillId: number) { this.skillId = skillId; - if (mainAPI['skills'][this.skillId.toString()] == undefined) { + if (bangdreamCatalogCache['skills'][this.skillId.toString()] == undefined) { this.isExist = false; return; } this.isExist = true; this.skillId = this.skillId; - this.data = mainAPI['skills'][this.skillId.toString()]; + this.data = bangdreamCatalogCache['skills'][this.skillId.toString()]; this.simpleDescription = this.data['simpleDescription']; this.description = this.data['description']; this.duration = this.data['duration']; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository.ts index 52a8cd2..dd8ce3f 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository.ts @@ -1,10 +1,10 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; export class CharacterResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer.ts index ef5631f..d75514b 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer.ts @@ -1,5 +1,5 @@ import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { match, FuzzySearchResult, @@ -31,7 +31,7 @@ export async function drawCharacterList( ): Promise> { //计算模糊搜索结果 const tempCharacterList: Array = []; //最终输出的角色列表 - const characterIdList: Array = Object.keys(mainAPI['characters']).map( + const characterIdList: Array = Object.keys(bangdreamCatalogCache['characters']).map( Number, ); //所有卡牌ID列表 for (let i = 0; i < characterIdList.length; i++) { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/character/character.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/character/character.model.ts index 91a0362..2e585b8 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/character/character.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/character/character.model.ts @@ -1,4 +1,4 @@ -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { Image, loadImage } from 'skia-canvas'; import { characterResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository'; @@ -38,7 +38,7 @@ export class Character { * @param characterId - 角色 ID。 */ constructor(characterId: number) { - const characterData = mainAPI['characters'][characterId.toString()]; + const characterData = bangdreamCatalogCache['characters'][characterId.toString()]; if (characterData == undefined) { this.isExist = false; return; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types.ts b/src/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types.ts similarity index 86% rename from src/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types.ts rename to src/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types.ts index 6a3b514..6737cf0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types.ts @@ -1,4 +1,4 @@ -export type QqbotBangDreamOperationKey = +export type BangDreamOperationKey = | 'bangdream.card.illustration' | 'bangdream.card.search' | 'bangdream.character.search' @@ -15,7 +15,7 @@ export type QqbotBangDreamOperationKey = | 'bangdream.song.random' | 'bangdream.song.search'; -export type QqbotBangDreamOperationHandlerName = +export type BangDreamOperationHandlerName = | 'getCardIllustration' | 'getCutoffAll' | 'getCutoffDetail' @@ -32,7 +32,7 @@ export type QqbotBangDreamOperationHandlerName = | 'searchSong' | 'simulateGacha'; -export type QqbotBangDreamCommandInput = { +export type BangDreamCommandInput = { args?: string[]; cardId?: number | string; compress?: boolean | string; @@ -55,9 +55,9 @@ export type QqbotBangDreamCommandInput = { useEasyBG?: boolean | string; }; -export type QqbotBangDreamCommandOutput = { +export type BangDreamCommandOutput = { imageCount: number; - operationKey: QqbotBangDreamOperationKey; + operationKey: BangDreamOperationKey; query: string; replyText: string; source: string; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer.ts index e116dc3..cf6a852 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer.ts @@ -15,7 +15,7 @@ import { drawCutoffChart } from '@/modules/qqbot/plugins/bangdream/src/domain/cu import { serverNameFullList } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; import { drawEventDataBlock } from '@/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer'; import { statusName } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { BangDreamEventStatus } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'; /** @@ -33,7 +33,7 @@ export async function drawCutoffDetail( mainServer: Server, compress: boolean, ): Promise> { - const eventData = bangDreamMainDataRepository.getEntity>( + const eventData = bangdreamCatalogRepository.getEntity>( 'events', eventId, ); diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.repository.ts index 7d11406..e0b646d 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; @@ -27,7 +27,7 @@ export interface CutoffEventTopData { export class CutoffEventTopRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff.model.ts index 087dbc7..a1f0ab0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff.model.ts @@ -1,7 +1,7 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; -import { bangDreamHhwxTrackerProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamHhwxTrackerProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { preferHhwxSource, reportDataSourceProblem, @@ -91,15 +91,15 @@ export class Cutoff { if (this.server != Server.cn) { // 非国服不使用HHWX this.useHHWX = false; - return bangDreamBestdoriProvider; + return bangdreamBestdoriProvider; } const provider = !reverse ? this.useHHWX - ? bangDreamHhwxTrackerProvider - : bangDreamBestdoriProvider + ? bangdreamHhwxTrackerProvider + : bangdreamBestdoriProvider : this.useHHWX - ? bangDreamBestdoriProvider - : bangDreamHhwxTrackerProvider; + ? bangdreamBestdoriProvider + : bangdreamHhwxTrackerProvider; if (reverse && this.server == Server.cn) this.useHHWX = !this.useHHWX; return provider; } @@ -236,7 +236,7 @@ export class Cutoff { } //rate const rateDataList = - bangDreamMainDataRepository.getValue< + bangdreamCatalogRepository.getValue< Array<{ server: number; type: string; tier: number; rate: number }> >('rates'); const rateData = rateDataList.find((element) => { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/event/event-data.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/event/event-data.repository.ts index 9dea8f4..69fad0a 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/event/event-data.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/event/event-data.repository.ts @@ -1,5 +1,5 @@ import { Canvas, Image, loadImage } from 'skia-canvas'; -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import { getServerByPriority, Server, @@ -35,7 +35,7 @@ type RewardWithId = { }; export class EventDataRepository { - constructor(private readonly provider = bangDreamBestdoriProvider) {} + constructor(private readonly provider = bangdreamBestdoriProvider) {} /** * 获取活动远端详情数据。 @@ -268,10 +268,10 @@ export class EventDataRepository { const rewardId = this.pickRewardId(event.rankingRewards, 'deco_pins'); if (rewardId === undefined) return undefined; - const { bangDreamMainDataRepository } = - await import('../../application/main-data.repository'); + const { bangdreamCatalogRepository } = + await import('../../application/catalog/bangdream-catalog-repository'); const allDeco = - bangDreamMainDataRepository.getCollection>('deco'); + bangdreamCatalogRepository.getCollection>('deco'); const decoAssetName = allDeco[rewardId]?.assetBundleName; if (!decoAssetName) return undefined; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/event/event-stage-data.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/event/event-stage-data.repository.ts index 08db7fd..f4e6ba1 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/event/event-stage-data.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/event/event-stage-data.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; export type EventStageDataType = 'stages' | 'rotationMusics'; @@ -20,7 +20,7 @@ export type EventStageDataRows = export class EventStageDataRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/event/event.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/event/event.model.ts index 978a9c9..9095c71 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/event/event.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/event/event.model.ts @@ -1,6 +1,6 @@ import { Canvas, Image } from 'skia-canvas'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { Attribute } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute.model'; import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; @@ -95,7 +95,7 @@ export class Event { */ constructor(eventId: number) { this.eventId = eventId; - const eventData = bangDreamMainDataRepository.getEntity< + const eventData = bangdreamCatalogRepository.getEntity< Record >('events', eventId); if (eventData == undefined) { @@ -337,7 +337,7 @@ export function getPresentEvent(server: Server, time?: number) { time = Date.now(); } const eventList: Array = []; - const eventListMain = bangDreamMainDataRepository.getCollection('events'); + const eventListMain = bangdreamCatalogRepository.getCollection('events'); for (const key in eventListMain) { const event = new Event(parseInt(key)); //如果在活动进行时 @@ -435,7 +435,7 @@ export function getRecentEventListByEventAndServer( count: number, sameType: boolean = false, ) { - const eventIdList = bangDreamMainDataRepository.getNumericIds('events'); + const eventIdList = bangdreamCatalogRepository.getNumericIds('events'); const candidates: CutoffRecentEventCandidate[] = eventIdList .map((eventId) => new Event(eventId)) .map((candidate) => ({ diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/event/event.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/event/event.repository.ts index bc51a0b..8877e11 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/event/event.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/event/event.repository.ts @@ -1,22 +1,22 @@ import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { - bangDreamMainDataRepository, - type BangDreamMainDataCollection, -} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; + bangdreamCatalogRepository, + type BangDreamCatalogCollection, +} from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; export class EventRepository { /** * 获取活动主数据集合。 */ - getSource(): BangDreamMainDataCollection { - return bangDreamMainDataRepository.getCollection('events'); + getSource(): BangDreamCatalogCollection { + return bangdreamCatalogRepository.getCollection('events'); } /** * 获取活动 ID 列表。 */ getEventIds(): number[] { - return bangDreamMainDataRepository.getNumericIds('events'); + return bangdreamCatalogRepository.getNumericIds('events'); } /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-resource.repository.ts index ae47f94..4bf287e 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-resource.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { getServerByPriority, @@ -22,7 +22,7 @@ function toServerCode(server: Server | undefined): string { export class GachaResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model.ts index cfe4072..3a49cad 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model.ts @@ -1,4 +1,4 @@ -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { Image, loadImage } from 'skia-canvas'; import { Server, @@ -79,7 +79,7 @@ export class Gacha { */ constructor(gachaId: number) { this.gachaId = gachaId; - const gachaData = bangDreamMainDataRepository.getEntity< + const gachaData = bangdreamCatalogRepository.getEntity< Record >('gacha', gachaId); if (gachaData == undefined) { @@ -247,7 +247,7 @@ export async function getPresentGachaList( end: number = Date.now(), ): Promise> { const gachaList: Array = []; - const gachaListMain = bangDreamMainDataRepository.getCollection('gacha'); + const gachaListMain = bangdreamCatalogRepository.getCollection('gacha'); for (const gachaId in gachaListMain) { if (Object.prototype.hasOwnProperty.call(gachaListMain, gachaId)) { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.repository.ts index d9207d3..4a45880 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.repository.ts @@ -1,15 +1,15 @@ import { Gacha } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model'; import { - bangDreamMainDataRepository, - type BangDreamMainDataCollection, -} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; + bangdreamCatalogRepository, + type BangDreamCatalogCollection, +} from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; export class GachaRepository { /** * 获取卡池主数据集合。 */ - getSource(): BangDreamMainDataCollection { - return bangDreamMainDataRepository.getCollection('gacha'); + getSource(): BangDreamCatalogCollection { + return bangdreamCatalogRepository.getCollection('gacha'); } /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/deck-rank.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/deck-rank.repository.ts index 6529319..571e8fe 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/deck-rank.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/deck-rank.repository.ts @@ -1,13 +1,13 @@ import * as path from 'node:path'; -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { assetsRootPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; import { readBangDreamAsset } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; export class DeckRankResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, private readonly localRankRootPath: string = path.join( assetsRootPath, 'Rank', diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-band-detail.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-band-detail.renderer.ts index 89cfa20..8f2f845 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-band-detail.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-band-detail.renderer.ts @@ -5,7 +5,7 @@ import { resizeImage } from '@/modules/qqbot/plugins/bangdream/src/theme/image-s import { Band } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model'; import { drawTextWithImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-text'; import { starList } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-rarity.renderer'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { BANGDREAM_DECK_TOTAL_RATING_ID, BANGDREAM_STAGE_CHALLENGE_BAND_ID, @@ -81,7 +81,7 @@ export async function drawPlayerBandRankInList( ): Promise { const bandRankMap = player.profile.bandRankMap?.entries; const BandDetails = {}; - for (const i in mainAPI['bands']) { + for (const i in bangdreamCatalogCache['bands']) { if (bandRankMap[i] != undefined) { BandDetails[i] = [bandRankMap[i].toString()]; } else { @@ -107,7 +107,7 @@ export async function drawPlayerStageChallengeRankInList( player.profile.stageChallengeAchievementConditionsMap.entries; const BandDetails = {}; - for (const band in mainAPI['bands']) { + for (const band in bangdreamCatalogCache['bands']) { const level = stageChallengeAchievementConditionsMap?.[ BANGDREAM_STAGE_CHALLENGE_BAND_ID[band] @@ -147,7 +147,7 @@ export async function drawPlayerDeckTotalRatingInList( const userDeckTotalRatingMap = player.profile.userDeckTotalRatingMap.entries; const BandDetails = {}; - for (const i in mainAPI['bands']) { + for (const i in bangdreamCatalogCache['bands']) { if (userDeckTotalRatingMap[i] != undefined) { const rankName = userDeckTotalRatingMap[i].rank; let rankId = BANGDREAM_DECK_TOTAL_RATING_ID[rankName]; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-character-detail.renderer.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-character-detail.renderer.ts index 21312c4..e564325 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-character-detail.renderer.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-character-detail.renderer.ts @@ -4,7 +4,7 @@ import { drawList } from '@/modules/qqbot/plugins/bangdream/src/theme/list-frame import { resizeImage } from '@/modules/qqbot/plugins/bangdream/src/theme/image-stack'; import { drawTextWithImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-text'; import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; -import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import bangdreamCatalogCache from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; import { createCharacterDetailIconSpec, createCharacterDetailItemLayout, @@ -69,7 +69,7 @@ async function drawCharacterInList( export async function drawCharacterRankInList(player: Player, key?: string) { const characterRankMap = player.profile.userCharacterRankMap?.entries; const CharacterDetailsInListOptions = {}; - for (const i in mainAPI['characters']) { + for (const i in bangdreamCatalogCache['characters']) { if (characterRankMap[i] != undefined) { CharacterDetailsInListOptions[i] = [`${characterRankMap[i].rank}`]; } else { diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-data.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-data.repository.ts index faa0761..1387eea 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-data.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-data.repository.ts @@ -1,4 +1,4 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; @@ -16,7 +16,7 @@ export interface PlayerDetailResponse { export class PlayerDataRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-ranking.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-ranking.repository.ts index 0f6f8f5..fb1e1ea 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/player/player-ranking.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/player/player-ranking.repository.ts @@ -1,10 +1,10 @@ -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; export class PlayerRankingResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/policy/cn-event-estimate.policy.ts b/src/modules/qqbot/plugins/bangdream/src/domain/policy/cn-event-estimate.policy.ts index 40d1b9a..95b4f7a 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/policy/cn-event-estimate.policy.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/policy/cn-event-estimate.policy.ts @@ -1,4 +1,4 @@ -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { BangDreamServerId as Server } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'; import { @@ -42,7 +42,7 @@ export interface CnEventEstimateCalculationContext { export function getBangDreamEventSchedule( eventId: number, ): BangDreamEventSchedule | null { - const eventData = bangDreamMainDataRepository.getEntity>( + const eventData = bangdreamCatalogRepository.getEntity>( 'events', eventId, ); @@ -64,7 +64,7 @@ export function getPresentBangDreamEventId( server: number, time = Date.now(), ): number | null { - const eventIds = bangDreamMainDataRepository.getNumericIds('events'); + const eventIds = bangdreamCatalogRepository.getNumericIds('events'); const activeEventIds: number[] = []; let latestEndedEventId: number | null = null; let latestEndedAt = 0; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rule.registry.ts b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rules.ts similarity index 97% rename from src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rule.registry.ts rename to src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rules.ts index 60040d2..e519d35 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rule.registry.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rules.ts @@ -23,7 +23,7 @@ export interface FuzzySearchRule { match: (keyword: FuzzySearchKeyword, push: FuzzySearchResultWriter) => void; } -export class FuzzySearchRuleRegistry { +export class FuzzySearchRules { constructor(private readonly rules: readonly FuzzySearchRule[]) {} /** @@ -61,10 +61,10 @@ export function createFuzzySearchKeyword( * * @param config - 搜索别名配置。 */ -export function createDefaultFuzzySearchRuleRegistry( +export function createDefaultFuzzySearchRules( config: FuzzySearchConfig, ) { - return new FuzzySearchRuleRegistry([ + return new FuzzySearchRules([ createNumberRule(), createLevelRule(), createRelationRule(), diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts index 67f4a17..9f0005a 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts @@ -1,7 +1,7 @@ import { - createDefaultFuzzySearchRuleRegistry, + createDefaultFuzzySearchRules, createFuzzySearchKeyword, -} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rule.registry'; +} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rules'; import { searchDictionaryRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/search/search-dictionary.repository'; import type { FuzzySearchConfig, @@ -23,8 +23,8 @@ const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g; const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); let cachedConfig: FuzzySearchConfig | undefined; -let cachedRuleRegistry: - | ReturnType +let cachedRules: + | ReturnType | undefined; export const config = new Proxy({} as FuzzySearchConfig, { @@ -38,11 +38,11 @@ function getFuzzySearchConfig() { return cachedConfig; } -function getRuleRegistry() { - cachedRuleRegistry ??= createDefaultFuzzySearchRuleRegistry( +function getFuzzySearchRules() { + cachedRules ??= createDefaultFuzzySearchRules( getFuzzySearchConfig(), ); - return cachedRuleRegistry; + return cachedRules; } /** @@ -105,7 +105,7 @@ export function fuzzySearch(keyword: string): FuzzySearchResult { const push = appendTo(matches); for (const rawKeyword of extractKeywords(keyword)) { - getRuleRegistry().match(createFuzzySearchKeyword(rawKeyword), push); + getFuzzySearchRules().match(createFuzzySearchKeyword(rawKeyword), push); } return matches; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository.ts index ad551da..7351540 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository.ts @@ -1,5 +1,5 @@ import { assetErrorImageBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; -import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; +import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider'; import { BANGDREAM_DIFFICULTY_NAME_BY_ID, @@ -31,7 +31,7 @@ function getServerCode(server: Server | undefined): string { export class SongResourceRepository { constructor( - private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider, ) {} /** diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts index e46ffe1..90a4d24 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.model.ts @@ -1,6 +1,6 @@ import { Image, loadImage } from 'skia-canvas'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; -import { bangDreamMainDataRepository } from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; +import { bangdreamCatalogRepository } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { BANGDREAM_DIFFICULTY_COLORS, @@ -99,7 +99,7 @@ export class Song { */ constructor(songId: number) { this.songId = songId; - const songData = bangDreamMainDataRepository.getEntity>( + const songData = bangdreamCatalogRepository.getEntity>( 'songs', songId, ); @@ -131,7 +131,7 @@ export class Song { } //meta数据 - const metaData = bangDreamMainDataRepository.getEntity>( + const metaData = bangdreamCatalogRepository.getEntity>( 'meta', songId, ); @@ -321,7 +321,7 @@ export function getPresentSongList( end: number = Date.now(), ): Song[] { const songList: Array = []; - const songListMain = bangDreamMainDataRepository.getCollection('songs'); + const songListMain = bangdreamCatalogRepository.getCollection('songs'); for (const songId in songListMain) { if (Object.prototype.hasOwnProperty.call(songListMain, songId)) { @@ -368,7 +368,7 @@ export function getMetaRanking( withFever: boolean, mainServer: Server, ): SongInRank[] { - const songIdList = bangDreamMainDataRepository.getNumericIds('meta'); + const songIdList = bangdreamCatalogRepository.getNumericIds('meta'); const songRankList: SongInRank[] = []; for (let i = 0; i < songIdList.length; i++) { const songId = songIdList[i]; diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.repository.ts b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.repository.ts index 276721e..b5b150e 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/song/song.repository.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/song/song.repository.ts @@ -1,15 +1,15 @@ import { Song } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'; import { - bangDreamMainDataRepository, - type BangDreamMainDataCollection, -} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; + bangdreamCatalogRepository, + type BangDreamCatalogCollection, +} from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository'; export class SongRepository { /** * 获取歌曲主数据集合。 */ - getSource(): BangDreamMainDataCollection { - return bangDreamMainDataRepository.getCollection('songs'); + getSource(): BangDreamCatalogCollection { + return bangdreamCatalogRepository.getCollection('songs'); } /** diff --git a/src/modules/qqbot/plugins/bangdream/src/index.ts b/src/modules/qqbot/plugins/bangdream/src/index.ts index ab9278d..72eb2a5 100644 --- a/src/modules/qqbot/plugins/bangdream/src/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/index.ts @@ -3,10 +3,10 @@ import { type BangDreamCommandContextOptions, } from './application/bangdream-command-context'; import { - createBangDreamHookContext, - createBangDreamLogHook, - BangDreamHookRegistry, -} from './application/hook/hook-registry'; + createBangDreamOperationLifecycleContext, + createBangDreamOperationLogObserver, + BangDreamOperationLifecycle, +} from './application/execution/operation-lifecycle'; import { configureBangDreamRuntimeIo, type BangDreamRuntimeIo, @@ -16,14 +16,14 @@ import { type BangDreamOperationModule, } from './operations'; import type { - QqbotBangDreamCommandInput, - QqbotBangDreamCommandOutput, - QqbotBangDreamOperationHandlerName, - QqbotBangDreamOperationKey, -} from './domain/common/qqbot-bangdream.types'; -import { waitForMainDataReady } from './application/main-data-store'; + BangDreamCommandInput, + BangDreamCommandOutput, + BangDreamOperationHandlerName, + BangDreamOperationKey, +} from './domain/common/bangdream.types'; +import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache'; -export type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & { +type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & { description?: string; io?: BangDreamRuntimeIo; legacyAliases?: string[]; @@ -34,13 +34,15 @@ export type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & { version?: string; }; -export type BangDreamManifestOperation = { +type BangDreamManifestOperation = { + aliases?: string[]; description?: string; - handlerName: QqbotBangDreamOperationHandlerName; + handlerName: BangDreamOperationHandlerName; inputSchema?: Record; - key: QqbotBangDreamOperationKey; + key: BangDreamOperationKey; name?: string; outputSchema?: Record; + timeoutMs?: number; }; type BangDreamResolvedOperation = BangDreamManifestOperation & { @@ -50,7 +52,9 @@ type BangDreamResolvedOperation = BangDreamManifestOperation & { export function createPlugin(options: BangDreamPluginRuntimeOptions) { if (options.io) configureBangDreamRuntimeIo(options.io); const context = new BangDreamCommandContext(options); - const hookRegistry = new BangDreamHookRegistry([createBangDreamLogHook()]); + const lifecycle = new BangDreamOperationLifecycle([ + createBangDreamOperationLogObserver(), + ]); const operationsByKey = resolveBangDreamOperations(options.operations); const normalizeError = options.normalizeError || @@ -59,12 +63,12 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) { 'BangDream 命令执行失败'); const executeOperation = ( - operationKey: QqbotBangDreamOperationKey, - input: QqbotBangDreamCommandInput, + operationKey: BangDreamOperationKey, + input: BangDreamCommandInput, ) => executeBangDreamOperation({ context, - hookRegistry, + lifecycle, input, normalizeError, operationKey, @@ -98,13 +102,15 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) { legacyKeys: options.legacyAliases, name: options.name || 'BangDream 查询', operations: options.operations.map((operation) => ({ + aliases: operation.aliases, cacheTtlMs: 60_000, description: operation.description, inputSchema: operation.inputSchema || getBangDreamInputSchema(), key: operation.key, name: operation.name || operation.key, outputSchema: operation.outputSchema || getBangDreamOutputSchema(), - execute: async (input: QqbotBangDreamCommandInput) => + timeoutMs: operation.timeoutMs, + execute: async (input: BangDreamCommandInput) => await executeOperation(operation.key, input), })), version: options.version || '2.0.0', @@ -134,21 +140,21 @@ function resolveBangDreamOperations(operations: BangDreamManifestOperation[]) { async function executeBangDreamOperation(options: { context: BangDreamCommandContext; - hookRegistry: BangDreamHookRegistry; - input: QqbotBangDreamCommandInput; + lifecycle: BangDreamOperationLifecycle; + input: BangDreamCommandInput; normalizeError: (error: unknown) => string; - operationKey: QqbotBangDreamOperationKey; - operationsByKey: Map; -}): Promise { - const operationContext = createBangDreamHookContext( + operationKey: BangDreamOperationKey; + operationsByKey: Map; +}): Promise { + const operationContext = createBangDreamOperationLifecycleContext( options.operationKey, options.input, ); - await options.hookRegistry.beforeParse(operationContext); + await options.lifecycle.beforeParse(operationContext); try { - operationContext.stage = 'mainData'; - await waitForMainDataReady(); + operationContext.stage = 'catalog'; + await waitForBangDreamCatalogReady(); operationContext.stage = 'operation'; const operation = options.operationsByKey.get(options.operationKey); @@ -156,31 +162,24 @@ async function executeBangDreamOperation(options: { throw new Error(`BangDream 插件能力不存在:${options.operationKey}`); } operationContext.handlerName = operation.handlerName; - await options.hookRegistry.afterResolve(operationContext); + await options.lifecycle.afterResolve(operationContext); operationContext.stage = 'handler'; - await options.hookRegistry.beforeRender(operationContext); + await options.lifecycle.beforeRender(operationContext); const output = await operation.execute(options.input, options.context); operationContext.stage = 'output'; operationContext.imageCount = output.imageCount; operationContext.query = output.query || operationContext.query; - await options.hookRegistry.afterOutput(operationContext); + await options.lifecycle.afterOutput(operationContext); return output; } catch (error) { const message = options.normalizeError(error); - await options.hookRegistry.onError(operationContext, message); + await options.lifecycle.onError(operationContext, message); throw new Error(message); } } -export type { - QqbotBangDreamCommandInput, - QqbotBangDreamCommandOutput, - QqbotBangDreamOperationHandlerName, - QqbotBangDreamOperationKey, -} from './domain/common/qqbot-bangdream.types'; - function getBangDreamInputSchema() { return { properties: { diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/api-cache.client.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/api-cache.client.ts index 244bbac..2862fca 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/api-cache.client.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/api-cache.client.ts @@ -1,4 +1,4 @@ -import { getJsonAndSave } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client'; +import { fetchRemoteResourceJson } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client'; import { getCacheDirectory, getFileNameFromUrl, @@ -32,7 +32,8 @@ async function callAPIAndCacheResponse( const cacheDir = getCacheDirectory(url); const fileName = getFileNameFromUrl(url); return await runWithCacheClientRetry({ - action: () => getJsonAndSave(url, cacheDir, fileName, cacheTime), + action: () => + fetchRemoteResourceJson(url, cacheDir, fileName, cacheTime), onFailure: (attempt, _retryCount, error) => { if (isCacheClientNotFound(error)) { logger( diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider.ts index d1cda1d..ac5b690 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider.ts @@ -104,4 +104,4 @@ export function createBestdoriProvider( ); } -export const bangDreamBestdoriProvider = createBestdoriProvider(); +export const bangdreamBestdoriProvider = createBestdoriProvider(); diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider.ts index b9941c2..5c11aca 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/hhwx-tracker.provider.ts @@ -75,4 +75,4 @@ export function createHhwxTrackerProvider( ); } -export const bangDreamHhwxTrackerProvider = createHhwxTrackerProvider(); +export const bangdreamHhwxTrackerProvider = createHhwxTrackerProvider(); diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts index c70243d..55ac5a8 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts @@ -120,4 +120,4 @@ export async function sleepBangDreamRuntime(ms: number) { await new Promise((resolve) => globalThis[`set${'Timeout'}`](resolve, ms)); } -export const bangDreamFallbackImageBuffer = defaultPng; +export const bangdreamFallbackImageBuffer = defaultPng; diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/asset-cache.client.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/asset-cache.client.ts index 4eaeb05..b5f6da0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/asset-cache.client.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/asset-cache.client.ts @@ -2,7 +2,7 @@ import { getCacheDirectory, getFileNameFromUrl, } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/cache-path'; -import { download } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client'; +import { fetchRemoteResourceBuffer } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client'; import { Buffer } from 'buffer'; import { assetErrorImageBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; @@ -53,7 +53,12 @@ async function downloadFile( return await runWithCacheClientRetry({ action: async () => { assetNotExists = false; - const data = await download(url, cacheDir, fileName, cacheTime); + const data = await fetchRemoteResourceBuffer( + url, + cacheDir, + fileName, + cacheTime, + ); const htmlSig = Buffer.from(''); const slice = Buffer.from(data.subarray(0, htmlSig.length)); if (slice.equals(htmlSig)) { diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client.ts similarity index 95% rename from src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client.ts rename to src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client.ts index 147fe03..83bfbfe 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client.ts @@ -36,7 +36,7 @@ function rememberNotFound(url: string, statusCode?: number) { if (statusCode === 404) errorUrlCache[url] = Date.now(); } -export async function download( +export async function fetchRemoteResourceBuffer( url: string, _directory?: string, _fileName?: string, @@ -66,7 +66,7 @@ export async function download( } } -export async function getJsonAndSave( +export async function fetchRemoteResourceJson( url: string, _directory?: string, _fileName?: string, diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider.ts index d081f66..18f1300 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider.ts @@ -29,4 +29,4 @@ export class BangDreamStaticPatchProvider { } } -export const bangDreamStaticPatchProvider = new BangDreamStaticPatchProvider(); +export const bangdreamStaticPatchProvider = new BangDreamStaticPatchProvider(); diff --git a/src/modules/qqbot/plugins/bangdream/src/operations/cutoff-detail/index.ts b/src/modules/qqbot/plugins/bangdream/src/operations/cutoff-detail/index.ts index 2b1bd23..872874c 100644 --- a/src/modules/qqbot/plugins/bangdream/src/operations/cutoff-detail/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/operations/cutoff-detail/index.ts @@ -3,15 +3,38 @@ import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangdream/src/domain import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; +const CUTOFF_DETAIL_COMMAND_ALIASES = new Set([ + 'ycx', + '预测线', + '查档线', + 'bd档线', +]); +const DEFAULT_CUTOFF_DETAIL_TIER = 1000; + export const cutoffDetailOperation: BangDreamOperationModule = { handlerName: 'getCutoffDetail', execute: async (input, context) => { const tokens = context.getTokens(input); - const tier = context.requireNumber(input.tier, tokens[0], '请提供档位'); - const mainServer = context.pickMainServer(input, tokens.slice(1)); + const hasCommandAlias = CUTOFF_DETAIL_COMMAND_ALIASES.has(tokens[0]); + const argumentTokens = hasCommandAlias ? tokens.slice(1) : tokens; + const explicitTier = context.optionalNumber(input.tier); + const tierFromText = context.optionalNumber(argumentTokens[0]); + const tier = + explicitTier ?? + tierFromText ?? + (hasCommandAlias || argumentTokens.length === 0 + ? DEFAULT_CUTOFF_DETAIL_TIER + : undefined); + if (tier === undefined) throw new Error('请提供档位'); + + const remainingTokens = + explicitTier === undefined && tierFromText !== undefined + ? argumentTokens.slice(1) + : argumentTokens; + const mainServer = context.pickMainServer(input, remainingTokens); const eventId = context.optionalNumber(input.eventId) ?? - context.firstNumber(tokens.slice(1)) ?? + context.firstNumber(remainingTokens) ?? getPresentEvent(mainServer).eventId; const options = context.getRenderOptions({ ...input, mainServer }); const images = diff --git a/src/modules/qqbot/plugins/bangdream/src/operations/operation.ts b/src/modules/qqbot/plugins/bangdream/src/operations/operation.ts index 07d9065..2d9eb84 100644 --- a/src/modules/qqbot/plugins/bangdream/src/operations/operation.ts +++ b/src/modules/qqbot/plugins/bangdream/src/operations/operation.ts @@ -1,17 +1,17 @@ import type { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context'; import type { - QqbotBangDreamCommandInput, - QqbotBangDreamCommandOutput, - QqbotBangDreamOperationHandlerName, -} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types'; + BangDreamCommandInput, + BangDreamCommandOutput, + BangDreamOperationHandlerName, +} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; export type BangDreamOperationExecute = ( - input: QqbotBangDreamCommandInput, + input: BangDreamCommandInput, context: BangDreamCommandContext, -) => Promise; +) => Promise; export type BangDreamOperationModule = { execute: BangDreamOperationExecute; expectedImageCount?: number; - handlerName: QqbotBangDreamOperationHandlerName; + handlerName: BangDreamOperationHandlerName; }; diff --git a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-image.ts b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-image.ts index 8390491..46e04c0 100644 --- a/src/modules/qqbot/plugins/bangdream/src/theme/canvas-image.ts +++ b/src/modules/qqbot/plugins/bangdream/src/theme/canvas-image.ts @@ -1,7 +1,7 @@ import { Canvas, loadImage, Image } from 'skia-canvas'; import * as svg2img from 'svg2img'; import { - bangDreamFallbackImageBuffer, + bangdreamFallbackImageBuffer, readBangDreamAsset, } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; @@ -10,7 +10,7 @@ const convertSvg = svg2img as unknown as ( callback: (error: Error | null, buffer: Buffer) => void, ) => void; -export const assetErrorImageBuffer = bangDreamFallbackImageBuffer; +export const assetErrorImageBuffer = bangdreamFallbackImageBuffer; /** * 在底层绘图工具层中加载图片FromPath。 diff --git a/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-application.ts b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-application.ts index 89ac135..2318ee0 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-application.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-application.ts @@ -1,4 +1,5 @@ import { Ff14MarketClient } from '../infrastructure/integration/ff14-market-client'; +import { parseFf14MarketPriceInput } from './ff14-market-input-parser'; export class Ff14MarketApplication { constructor(private readonly client: Ff14MarketClient) {} @@ -7,6 +8,13 @@ export class Ff14MarketApplication { return this.client.getPrice(input); } + async parsePriceInput(rawArgs: string) { + return parseFf14MarketPriceInput( + rawArgs, + await this.client.getMarketCatalog(), + ); + } + async resolveItem(input: Record) { return this.client.resolveItem(input); } diff --git a/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts index c5843ef..2452768 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts @@ -1,13 +1,13 @@ -import type { QqbotFf14MarketCatalog } from '../domain/ff14-market.types'; +import type { Ff14MarketCatalog } from '../domain/ff14-market.types'; import { - isQqbotFf14DataCenterName, - isQqbotFf14LocationName, - isQqbotFf14RegionName, - isQqbotFf14WorldName, - splitQqbotFf14WorldPath, + isFf14DataCenterName, + isFf14LocationName, + isFf14RegionName, + isFf14WorldName, + splitFf14WorldPath, } from '../domain/ff14-worlds'; -export type QqbotFf14MarketPriceInput = { +export type Ff14MarketPriceInput = { dataCenter?: string; hq?: boolean; item?: string; @@ -17,10 +17,10 @@ export type QqbotFf14MarketPriceInput = { world?: string; }; -export function parseQqbotFf14MarketPriceInput( +export function parseFf14MarketPriceInput( rawArgs: string, - catalog: QqbotFf14MarketCatalog, -): QqbotFf14MarketPriceInput { + catalog: Ff14MarketCatalog, +): Ff14MarketPriceInput { const tokens = rawArgs.split(/\s+/).filter(Boolean); const flags = new Map(); const positional: string[] = []; @@ -53,7 +53,7 @@ export function parseQqbotFf14MarketPriceInput( ); let item = positional.join(' '); - const worldPath = splitQqbotFf14WorldPath(world); + const worldPath = splitFf14WorldPath(world); if (worldPath.dataCenter && worldPath.world) { dataCenter = dataCenter || worldPath.dataCenter; region = region || worldPath.region || ''; @@ -71,7 +71,7 @@ export function parseQqbotFf14MarketPriceInput( } if (item.includes('@')) { const [itemName, worldName] = item.split('@'); - const itemWorldPath = splitQqbotFf14WorldPath(worldName); + const itemWorldPath = splitFf14WorldPath(worldName); item = itemName.trim(); dataCenter = dataCenter || itemWorldPath.dataCenter || ''; region = region || itemWorldPath.region || ''; @@ -90,13 +90,13 @@ export function parseQqbotFf14MarketPriceInput( } function pickTrailingFf14Location( - catalog: QqbotFf14MarketCatalog, + catalog: Ff14MarketCatalog, positional: string[], ) { const last = positional[positional.length - 1]; - if (!isQqbotFf14LocationName(catalog, last)) return null; + if (!isFf14LocationName(catalog, last)) return null; - const path = splitQqbotFf14WorldPath(last); + const path = splitFf14WorldPath(last); if (path.dataCenter && path.world) { return { dataCenter: path.dataCenter, @@ -110,11 +110,11 @@ function pickTrailingFf14Location( const beforePrevious = positional[positional.length - 3]; if ( previous && - isQqbotFf14DataCenterName(catalog, previous) && - isQqbotFf14WorldName(catalog, last) + isFf14DataCenterName(catalog, previous) && + isFf14WorldName(catalog, last) ) { const hasRegion = - beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious); + beforePrevious && isFf14RegionName(catalog, beforePrevious); return { dataCenter: previous, item: positional.slice(0, hasRegion ? -3 : -2).join(' '), @@ -125,8 +125,8 @@ function pickTrailingFf14Location( if ( previous && - isQqbotFf14RegionName(catalog, previous) && - isQqbotFf14DataCenterName(catalog, last) + isFf14RegionName(catalog, previous) && + isFf14DataCenterName(catalog, last) ) { return { dataCenter: last, diff --git a/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-market.types.ts b/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-market.types.ts index 870eaa7..d397736 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-market.types.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-market.types.ts @@ -1,18 +1,18 @@ export type Ff14HttpMethod = 'GET'; -export type QqbotFf14DataCenter = { +export type Ff14DataCenter = { name: string; region: string; worlds: string[]; }; -export type QqbotFf14MarketCatalog = { - dataCenters: QqbotFf14DataCenter[]; +export type Ff14MarketCatalog = { + dataCenters: Ff14DataCenter[]; defaultRegion?: string; regions: string[]; }; -export type QqbotFf14MarketTarget = { +export type Ff14MarketTarget = { dataCenter?: string; label: string; region?: string; @@ -20,7 +20,7 @@ export type QqbotFf14MarketTarget = { world?: string; }; -export type QqbotFf14ResolvedItem = { +export type Ff14ResolvedItem = { icon?: string; isUntradable?: boolean; itemId: number; @@ -38,10 +38,10 @@ export type UniversalisListing = { worldName?: string; }; -export type QqbotFf14PriceResult = { +export type Ff14PriceResult = { averagePrice?: number; hq?: boolean; - item: QqbotFf14ResolvedItem; + item: Ff14ResolvedItem; listings: UniversalisListing[]; minPrice?: number; replyText: string; diff --git a/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds.ts b/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds.ts index 7771b21..a39d0cd 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds.ts @@ -1,7 +1,7 @@ import type { - QqbotFf14DataCenter, - QqbotFf14MarketCatalog, - QqbotFf14MarketTarget, + Ff14DataCenter, + Ff14MarketCatalog, + Ff14MarketTarget, } from './ff14-market.types'; export type Ff14DictItem = { @@ -22,11 +22,11 @@ export const QQBOT_FF14_MARKET_DICT_CODES = { world: 'FF14_MARKET_WORLD', }; -export function buildQqbotFf14MarketCatalog(input: { +export function buildFf14MarketCatalog(input: { dataCenters: Ff14DictItem[]; regions: Ff14DictItem[]; worlds: Ff14DictItem[]; -}): QqbotFf14MarketCatalog { +}): Ff14MarketCatalog { const regions = input.regions.map(getDictDisplayValue).filter(Boolean); const defaultRegion = regions[0]; const dataCenters = input.dataCenters @@ -36,14 +36,14 @@ export function buildQqbotFf14MarketCatalog(input: { return { name, region: - normalizeQqbotFf14WorldValue(item.childrenCode) || defaultRegion, + normalizeFf14WorldValue(item.childrenCode) || defaultRegion, worlds: input.worlds .filter(({ childrenCode }) => childrenCode === getDictRawValue(item)) .map(getDictDisplayValue) .filter(Boolean), }; }) - .filter((item): item is QqbotFf14DataCenter => !!item); + .filter((item): item is Ff14DataCenter => !!item); return { dataCenters, @@ -52,9 +52,9 @@ export function buildQqbotFf14MarketCatalog(input: { }; } -export function buildQqbotFf14MarketCatalogFromTree( +export function buildFf14MarketCatalogFromTree( roots: Ff14DictItem[], -): QqbotFf14MarketCatalog { +): Ff14MarketCatalog { const regionNodes = roots.filter( (item) => item.dictCode === QQBOT_FF14_MARKET_DICT_CODES.region, ); @@ -76,7 +76,7 @@ export function buildQqbotFf14MarketCatalogFromTree( .filter(Boolean), }; }) - .filter((item): item is QqbotFf14DataCenter => !!item); + .filter((item): item is Ff14DataCenter => !!item); }); return { @@ -86,46 +86,46 @@ export function buildQqbotFf14MarketCatalogFromTree( }; } -export function isQqbotFf14DataCenterName( - catalog: QqbotFf14MarketCatalog, +export function isFf14DataCenterName( + catalog: Ff14MarketCatalog, value?: string, ) { - const name = normalizeQqbotFf14WorldValue(value); + const name = normalizeFf14WorldValue(value); return catalog.dataCenters.some((item) => item.name === name); } -export function isQqbotFf14RegionName( - catalog: QqbotFf14MarketCatalog, +export function isFf14RegionName( + catalog: Ff14MarketCatalog, value?: string, ) { - const name = normalizeQqbotFf14WorldValue(value); + const name = normalizeFf14WorldValue(value); return catalog.regions.includes(name); } -export function isQqbotFf14WorldName( - catalog: QqbotFf14MarketCatalog, +export function isFf14WorldName( + catalog: Ff14MarketCatalog, value?: string, ) { - const name = normalizeQqbotFf14WorldValue(value); + const name = normalizeFf14WorldValue(value); return catalog.dataCenters.some((item) => item.worlds.includes(name)); } -export function isQqbotFf14LocationName( - catalog: QqbotFf14MarketCatalog, +export function isFf14LocationName( + catalog: Ff14MarketCatalog, value?: string, ) { - const name = normalizeQqbotFf14WorldValue(value); - const path = splitQqbotFf14WorldPath(name); + const name = normalizeFf14WorldValue(value); + const path = splitFf14WorldPath(name); return ( - isQqbotFf14RegionName(catalog, name) || - isQqbotFf14DataCenterName(catalog, name) || - isQqbotFf14WorldName(catalog, name) || + isFf14RegionName(catalog, name) || + isFf14DataCenterName(catalog, name) || + isFf14WorldName(catalog, name) || (!!path.dataCenter && !!path.world) ); } -export function splitQqbotFf14WorldPath(value?: string) { - const raw = normalizeQqbotFf14WorldValue(value); +export function splitFf14WorldPath(value?: string) { + const raw = normalizeFf14WorldValue(value); if (!raw) return {}; const parts = raw @@ -148,31 +148,31 @@ export function splitQqbotFf14WorldPath(value?: string) { }; } -export function findQqbotFf14DataCenterByWorld( - catalog: QqbotFf14MarketCatalog, +export function findFf14DataCenterByWorld( + catalog: Ff14MarketCatalog, world?: string, ) { - const worldName = normalizeQqbotFf14WorldValue(world); + const worldName = normalizeFf14WorldValue(world); return catalog.dataCenters.find((item) => item.worlds.includes(worldName)); } -export function resolveQqbotFf14MarketTarget( - catalog: QqbotFf14MarketCatalog, +export function resolveFf14MarketTarget( + catalog: Ff14MarketCatalog, params: { dataCenter?: string; fallback?: string; region?: string; world?: string; }, -): QqbotFf14MarketTarget { +): Ff14MarketTarget { const defaultRegion = catalog.defaultRegion || ''; - const fallback = normalizeQqbotFf14WorldValue(params.fallback); - const path = splitQqbotFf14WorldPath(params.world); - const region = normalizeQqbotFf14WorldValue(params.region || path.region); - const dataCenter = normalizeQqbotFf14WorldValue( + const fallback = normalizeFf14WorldValue(params.fallback); + const path = splitFf14WorldPath(params.world); + const region = normalizeFf14WorldValue(params.region || path.region); + const dataCenter = normalizeFf14WorldValue( params.dataCenter || path.dataCenter, ); - const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world); + const rawWorld = normalizeFf14WorldValue(path.world || params.world); const world = dataCenter && rawWorld === defaultRegion ? '' : rawWorld; const raw = world || dataCenter || region || fallback || defaultRegion; @@ -211,7 +211,7 @@ export function resolveQqbotFf14MarketTarget( }; } - const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(catalog, raw); + const matchedWorldDataCenter = findFf14DataCenterByWorld(catalog, raw); if (matchedWorldDataCenter) { return { dataCenter: matchedWorldDataCenter.name, @@ -222,7 +222,7 @@ export function resolveQqbotFf14MarketTarget( }; } - if (isQqbotFf14DataCenterName(catalog, raw)) { + if (isFf14DataCenterName(catalog, raw)) { return { dataCenter: raw, label: defaultRegion ? `${defaultRegion} / ${raw}` : raw, @@ -238,13 +238,13 @@ export function resolveQqbotFf14MarketTarget( } function getDictDisplayValue(item: Ff14DictItem) { - return normalizeQqbotFf14WorldValue(item.label || item.value); + return normalizeFf14WorldValue(item.label || item.value); } function getDictRawValue(item: Ff14DictItem) { - return normalizeQqbotFf14WorldValue(item.value || item.label); + return normalizeFf14WorldValue(item.value || item.label); } -function normalizeQqbotFf14WorldValue(value?: string | null) { +function normalizeFf14WorldValue(value?: string | null) { return `${value || ''}`.trim(); } diff --git a/src/modules/qqbot/plugins/ff14-market/src/index.ts b/src/modules/qqbot/plugins/ff14-market/src/index.ts index 0a54752..6fc5a5e 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/index.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/index.ts @@ -5,7 +5,7 @@ import { type Ff14MarketManifest, } from './operations'; -export type Ff14MarketPluginOptions = { +type Ff14MarketPluginOptions = { host: import('./infrastructure/integration/ff14-market-client').Ff14MarketPluginHost; manifest: Ff14MarketManifest; normalizeError?: (error: unknown, fallback: string) => string; @@ -64,26 +64,3 @@ function formatFf14CheckedAt(date: Date) { pad(date.getSeconds()), ].join(''); } - -export { - parseQqbotFf14MarketPriceInput, - type QqbotFf14MarketPriceInput, -} from './application/ff14-market-input-parser'; -export { - buildQqbotFf14MarketCatalog, - buildQqbotFf14MarketCatalogFromTree, - findQqbotFf14DataCenterByWorld, - isQqbotFf14DataCenterName, - isQqbotFf14LocationName, - isQqbotFf14RegionName, - isQqbotFf14WorldName, - QQBOT_FF14_MARKET_DICT_CODES, - resolveQqbotFf14MarketTarget, - splitQqbotFf14WorldPath, -} from './domain/ff14-worlds'; -export type { - QqbotFf14MarketCatalog, - QqbotFf14PriceResult, - QqbotFf14ResolvedItem, -} from './domain/ff14-market.types'; -export type { Ff14MarketPluginHost } from './infrastructure/integration/ff14-market-client'; diff --git a/src/modules/qqbot/plugins/ff14-market/src/infrastructure/integration/ff14-market-client.ts b/src/modules/qqbot/plugins/ff14-market/src/infrastructure/integration/ff14-market-client.ts index 45bc1ff..7345e46 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/infrastructure/integration/ff14-market-client.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/infrastructure/integration/ff14-market-client.ts @@ -1,14 +1,14 @@ import { - buildQqbotFf14MarketCatalog, - buildQqbotFf14MarketCatalogFromTree, + buildFf14MarketCatalog, + buildFf14MarketCatalogFromTree, QQBOT_FF14_MARKET_DICT_CODES, - resolveQqbotFf14MarketTarget, + resolveFf14MarketTarget, } from '../../domain/ff14-worlds'; import { resolveFf14MarketConfig } from '../../config/ff14-market-config'; import type { Ff14HttpMethod, - QqbotFf14PriceResult, - QqbotFf14ResolvedItem, + Ff14PriceResult, + Ff14ResolvedItem, UniversalisListing, UniversalisMarketResponse, XivapiSearchItem, @@ -54,7 +54,7 @@ export class Ff14MarketClient { item?: string; itemId?: number | string; language?: string; - }): Promise { + }): Promise { const language = this.normalizeXivapiLanguage(params.language); const itemId = Number(params.itemId || params.item); if (Number.isInteger(itemId) && itemId > 0) { @@ -84,7 +84,7 @@ export class Ff14MarketClient { language?: string; region?: string; world?: string; - }): Promise { + }): Promise { const marketTarget = await this.resolveMarketTarget(params); const item = await this.resolveItem(params); if (item.isUntradable) { @@ -148,7 +148,7 @@ export class Ff14MarketClient { itemId: number, language = 'chs', displayName?: string, - ): Promise { + ): Promise { const normalizedLanguage = this.normalizeXivapiLanguage(language); const url = this.buildXivapiUrl( `/sheet/Item/${itemId}`, @@ -171,7 +171,7 @@ export class Ff14MarketClient { }; } - private buildReplyText(result: Omit) { + private buildReplyText(result: Omit) { const listingText = result.listings.length ? result.listings .slice(0, 10) @@ -243,8 +243,8 @@ export class Ff14MarketClient { region?: string; world?: string; }) { - const catalog = await this.getFf14MarketCatalog(); - return resolveQqbotFf14MarketTarget(catalog, { + const catalog = await this.getMarketCatalog(); + return resolveFf14MarketTarget(catalog, { dataCenter: params.dataCenter, fallback: this.normalizeWorld(params.world, catalog.defaultRegion), region: params.region, @@ -252,8 +252,8 @@ export class Ff14MarketClient { }); } - private async getFf14MarketCatalog() { - const treeCatalog = buildQqbotFf14MarketCatalogFromTree( + async getMarketCatalog() { + const treeCatalog = buildFf14MarketCatalogFromTree( await this.host.relationTree({ dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, }), @@ -267,7 +267,7 @@ export class Ff14MarketClient { ), this.host.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), ]); - return buildQqbotFf14MarketCatalog({ + return buildFf14MarketCatalog({ dataCenters, regions, worlds, diff --git a/src/modules/qqbot/plugins/ff14-market/src/operations/index.ts b/src/modules/qqbot/plugins/ff14-market/src/operations/index.ts index 42ca165..e5d92f9 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/operations/index.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/operations/index.ts @@ -1,7 +1,7 @@ import type { Ff14MarketApplication } from '../application/ff14-market-application'; import { createFf14MarketPriceOperation, - ff14MarketPriceHandlerName, + ff14PricePriceHandlerName, } from './market-price'; import type { Ff14MarketManifestOperation } from './operation-manifest'; import { @@ -14,7 +14,7 @@ export function buildFf14MarketOperations( operations: Ff14MarketManifestOperation[], ) { const operationFactories = { - [ff14MarketPriceHandlerName]: () => + [ff14PricePriceHandlerName]: () => createFf14MarketPriceOperation(application), [ff14ResolveItemHandlerName]: () => createFf14ResolveItemOperation(application), @@ -34,6 +34,7 @@ export function buildFf14MarketOperations( } const implementation = factory(); return { + aliases: operation.aliases, cacheTtlMs: operation.cacheTtlMs, description: operation.description, execute: implementation.execute, @@ -41,6 +42,7 @@ export function buildFf14MarketOperations( key: operation.key, name: operation.name, outputSchema: operation.outputSchema || implementation.outputSchema, + timeoutMs: operation.timeoutMs, }; }); } diff --git a/src/modules/qqbot/plugins/ff14-market/src/operations/market-price.ts b/src/modules/qqbot/plugins/ff14-market/src/operations/market-price.ts index d7e098e..de8cad1 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/operations/market-price.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/operations/market-price.ts @@ -1,12 +1,16 @@ import type { Ff14MarketApplication } from '../application/ff14-market-application'; -export const ff14MarketPriceHandlerName = 'getPrice'; +export const ff14PricePriceHandlerName = 'getPrice'; export function createFf14MarketPriceOperation( application: Ff14MarketApplication, ) { return { - execute: (input: Record) => application.getPrice(input), + execute: async (input: Record) => { + const raw = `${input.raw ?? input.text ?? ''}`.trim(); + const parsed = raw ? await application.parsePriceInput(raw) : {}; + return application.getPrice(removeEmpty({ ...input, ...parsed })); + }, inputSchema: { properties: { dataCenter: { description: '大区名,如陆行鸟', type: 'string' }, @@ -31,3 +35,13 @@ export function createFf14MarketPriceOperation( }, }; } + +function removeEmpty(input: Record) { + return Object.entries(input).reduce>( + (result, [key, value]) => { + if (value !== undefined && value !== '') result[key] = value; + return result; + }, + {}, + ); +} diff --git a/src/modules/qqbot/plugins/ff14-market/src/operations/operation-manifest.ts b/src/modules/qqbot/plugins/ff14-market/src/operations/operation-manifest.ts index 7f7b942..178f3f7 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/operations/operation-manifest.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/operations/operation-manifest.ts @@ -1,4 +1,5 @@ export type Ff14MarketManifestOperation = { + aliases?: string[]; cacheTtlMs?: number; description?: string; handlerName: string; @@ -6,6 +7,7 @@ export type Ff14MarketManifestOperation = { key: string; name: string; outputSchema?: Record; + timeoutMs?: number; }; export type Ff14MarketManifest = { diff --git a/src/modules/qqbot/plugins/ff14-market/src/operations/resolve-item.ts b/src/modules/qqbot/plugins/ff14-market/src/operations/resolve-item.ts index 2baba43..a5cf8d0 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/operations/resolve-item.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/operations/resolve-item.ts @@ -6,7 +6,11 @@ export function createFf14ResolveItemOperation( application: Ff14MarketApplication, ) { return { - execute: (input: Record) => application.resolveItem(input), + execute: (input: Record) => + application.resolveItem({ + ...input, + item: input.item || input.raw || input.text, + }), inputSchema: { properties: { item: { description: '物品名称或 ID', type: 'string' }, diff --git a/src/modules/qqbot/plugins/fflogs/src/application/fflogs-application.ts b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-application.ts index 8ec8e4d..caed74b 100644 --- a/src/modules/qqbot/plugins/fflogs/src/application/fflogs-application.ts +++ b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-application.ts @@ -1,15 +1,23 @@ import { FflogsClient } from '../infrastructure/integration/fflogs-client'; -import type { QqbotFflogsCharacterSummaryInput } from '../domain/fflogs.types'; +import type { FflogsCharacterSummaryInput } from '../domain/fflogs.types'; +import { parseFflogsCharacterInput } from './fflogs-input-parser'; export class FflogsApplication { constructor(private readonly client: FflogsClient) {} async getCharacterSummary(input: Record) { return this.client.getCharacterSummary( - input as QqbotFflogsCharacterSummaryInput, + input as FflogsCharacterSummaryInput, ); } + async parseCharacterInput(rawArgs: string) { + const tokens = rawArgs.split(/\s+/).filter(Boolean); + return parseFflogsCharacterInput(rawArgs, { + resolveKnownWorld: await this.client.buildKnownWorldResolver(tokens), + }); + } + async checkHealth() { await this.client.checkHealth(); return true; diff --git a/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts index c6be0e4..ac77a5f 100644 --- a/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts +++ b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts @@ -1,14 +1,14 @@ -export type QqbotFflogsKnownWorldResolver = ( +export type FflogsKnownWorldResolver = ( value: string, ) => null | { serverSlug?: string }; -export type QqbotFflogsCharacterInputParseOptions = { - resolveKnownWorld?: QqbotFflogsKnownWorldResolver; +export type FflogsCharacterInputParseOptions = { + resolveKnownWorld?: FflogsKnownWorldResolver; }; -export function parseQqbotFflogsCharacterInput( +export function parseFflogsCharacterInput( rawArgs: string, - options: QqbotFflogsCharacterInputParseOptions = {}, + options: FflogsCharacterInputParseOptions = {}, ) { const tokens = rawArgs.split(/\s+/).filter(Boolean); const flags = new Map(); @@ -130,7 +130,7 @@ export function parseQqbotFflogsCharacterInput( function pickPositionalsByKnownWorld( positional: string[], - resolveKnownWorld?: QqbotFflogsKnownWorldResolver, + resolveKnownWorld?: FflogsKnownWorldResolver, ) { if (!resolveKnownWorld) return null; diff --git a/src/modules/qqbot/plugins/fflogs/src/domain/fflogs.types.ts b/src/modules/qqbot/plugins/fflogs/src/domain/fflogs.types.ts index 3e05e83..9b09c57 100644 --- a/src/modules/qqbot/plugins/fflogs/src/domain/fflogs.types.ts +++ b/src/modules/qqbot/plugins/fflogs/src/domain/fflogs.types.ts @@ -105,7 +105,7 @@ export type FflogsTokenResponse = { token_type?: string; }; -export type QqbotFflogsCharacterSummaryInput = { +export type FflogsCharacterSummaryInput = { character?: string; characterName?: string; className?: string; @@ -126,13 +126,13 @@ export type QqbotFflogsCharacterSummaryInput = { zoneId?: number | string; }; -export type QqbotFflogsCharacterSummaryResult = { +export type FflogsCharacterSummaryResult = { allStarText?: string; characterId?: number; characterName: string; encounterName?: string; encounterSuggestions?: string[]; - logs?: QqbotFflogsEncounterLogItem[]; + logs?: FflogsEncounterLogItem[]; rankingSuggestions?: string[]; rankings: FflogsRankingItem[]; replyText: string; @@ -141,7 +141,7 @@ export type QqbotFflogsCharacterSummaryResult = { url: string; }; -export type QqbotFflogsEncounterLogItem = { +export type FflogsEncounterLogItem = { adps?: number; color: string; damageScore?: number; diff --git a/src/modules/qqbot/plugins/fflogs/src/index.ts b/src/modules/qqbot/plugins/fflogs/src/index.ts index b2e4f3d..37a0e4a 100644 --- a/src/modules/qqbot/plugins/fflogs/src/index.ts +++ b/src/modules/qqbot/plugins/fflogs/src/index.ts @@ -2,7 +2,7 @@ import { FflogsApplication } from './application/fflogs-application'; import { FflogsClient } from './infrastructure/integration/fflogs-client'; import { buildFflogsOperations, type FflogsManifest } from './operations'; -export type FflogsPluginOptions = { +type FflogsPluginOptions = { host: import('./infrastructure/integration/fflogs-client').FflogsPluginHost; manifest: FflogsManifest; normalizeError?: (error: unknown, fallback: string) => string; @@ -55,14 +55,3 @@ function formatFflogsCheckedAt(date: Date) { pad(date.getSeconds()), ].join(''); } - -export type { - QqbotFflogsCharacterSummaryInput, - QqbotFflogsCharacterSummaryResult, -} from './domain/fflogs.types'; -export { - parseQqbotFflogsCharacterInput, - type QqbotFflogsCharacterInputParseOptions, - type QqbotFflogsKnownWorldResolver, -} from './application/fflogs-input-parser'; -export type { FflogsPluginHost } from './infrastructure/integration/fflogs-client'; diff --git a/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts b/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts index 260d70d..2f44e72 100644 --- a/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts +++ b/src/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client.ts @@ -12,10 +12,13 @@ import type { FflogsReportFight, FflogsReportFightMetricsResponse, FflogsTokenResponse, - QqbotFflogsCharacterSummaryInput, - QqbotFflogsCharacterSummaryResult, - QqbotFflogsEncounterLogItem, + FflogsCharacterSummaryInput, + FflogsCharacterSummaryResult, + FflogsEncounterLogItem, } from '../../domain/fflogs.types'; +import type { + FflogsKnownWorldResolver, +} from '../../application/fflogs-input-parser'; import { resolveFflogsConfig } from '../../config/fflogs-config'; import { FflogsOAuthTokenCache } from '../storage/oauth-token-cache'; @@ -50,6 +53,9 @@ export type FflogsPluginHost = { timeoutMs: number; url: URL; }) => Promise; + resolveKnownWorld?: ( + value: string, + ) => Promise; }; export class FflogsClient { @@ -79,8 +85,8 @@ export class FflogsClient { } async getCharacterSummary( - params: QqbotFflogsCharacterSummaryInput, - ): Promise { + params: FflogsCharacterSummaryInput, + ): Promise { const characterName = `${ params.characterName || params.character || '' }`.trim(); @@ -123,7 +129,7 @@ export class FflogsClient { }; const data = await this.requestGraphql( - `query QqbotFflogsCharacterSummary( + `query FflogsCharacterSummary( $characterName: String! $serverSlug: String! $serverRegion: String! @@ -207,9 +213,27 @@ export class FflogsClient { }; } + async buildKnownWorldResolver( + candidates: string[], + ): Promise { + const resolved = new Map(); + await Promise.all( + candidates.map(async (candidate) => { + const key = `${candidate || ''}`.trim(); + if (!key || resolved.has(key)) return; + resolved.set(key, await this.resolveKnownWorld(key)); + }), + ); + return (value: string) => resolved.get(value) || null; + } + + async resolveKnownWorld(value: string) { + return this.host.resolveKnownWorld?.(value) || null; + } + private async getCharacterEncounterLogs( - params: QqbotFflogsCharacterSummaryInput, - ): Promise { + params: FflogsCharacterSummaryInput, + ): Promise { const characterName = `${ params.characterName || params.character || '' }`.trim(); @@ -238,7 +262,7 @@ export class FflogsClient { }; const data = await this.requestGraphql( - `query QqbotFflogsCharacterEncounterReports( + `query FflogsCharacterEncounterReports( $characterName: String! $serverSlug: String! $serverRegion: String! @@ -402,7 +426,7 @@ export class FflogsClient { if (params.encounterLookup.encounterId === undefined) return []; const data = await this.requestGraphql( - `query QqbotFflogsCharacterEncounterRankings( + `query FflogsCharacterEncounterRankings( $characterName: String! $serverSlug: String! $serverRegion: String! @@ -468,7 +492,7 @@ export class FflogsClient { damageRank: any, healingRank: any, encounterLookup: FflogsEncounterLookup, - ): QqbotFflogsEncounterLogItem | null { + ): FflogsEncounterLogItem | null { const code = `${damageRank?.report?.code || ''}`.trim(); const fightId = this.toOptionalNumber(damageRank?.report?.fightID); if (!code || fightId === undefined) return null; @@ -521,7 +545,7 @@ export class FflogsClient { serverName: string; serverRegion: string; serverSlug: string; - }): Promise { + }): Promise { const { candidate } = params; const code = `${candidate.report.code || ''}`.trim(); const fightId = this.toOptionalNumber(candidate.fight.id); @@ -531,7 +555,7 @@ export class FflogsClient { } const data = await this.requestGraphql( - `query QqbotFflogsEncounterFightMetrics( + `query FflogsEncounterFightMetrics( $code: String! $encounterID: Int! $fightIDs: [Int] @@ -760,7 +784,7 @@ export class FflogsClient { encounterName: string; encounterSuggestions?: string[]; localizationMaps: FflogsLocalizationMaps; - logs: QqbotFflogsEncounterLogItem[]; + logs: FflogsEncounterLogItem[]; rankingSuggestions?: string[]; serverName: string; serverRegion: string; @@ -809,7 +833,7 @@ export class FflogsClient { } private formatEncounterLogLine( - item: QqbotFflogsEncounterLogItem, + item: FflogsEncounterLogItem, index: number, ) { const status = @@ -982,7 +1006,7 @@ export class FflogsClient { }>; }; }>( - `query QqbotFflogsEncounterCatalog { + `query FflogsEncounterCatalog { worldData { zones { id @@ -1259,7 +1283,7 @@ export class FflogsClient { )}#fight=${encodeURIComponent(`${fightId}`)}`; } - private normalizeEncounterInput(params: QqbotFflogsCharacterSummaryInput) { + private normalizeEncounterInput(params: FflogsCharacterSummaryInput) { return `${params.encounterName || params.encounter || ''}`.trim(); } diff --git a/src/modules/qqbot/plugins/fflogs/src/operations/character-summary.ts b/src/modules/qqbot/plugins/fflogs/src/operations/character-summary.ts index 389f3aa..2ef3279 100644 --- a/src/modules/qqbot/plugins/fflogs/src/operations/character-summary.ts +++ b/src/modules/qqbot/plugins/fflogs/src/operations/character-summary.ts @@ -7,8 +7,13 @@ export function createFflogsCharacterSummaryOperation( ) { return { cacheTtlMs: 60_000, - execute: (input: Record) => - application.getCharacterSummary(input), + execute: async (input: Record) => { + const raw = `${input.raw ?? input.text ?? ''}`.trim(); + const parsed = raw ? await application.parseCharacterInput(raw) : {}; + return application.getCharacterSummary( + removeEmpty({ ...input, ...parsed }), + ); + }, inputSchema: { properties: { characterName: { description: '角色名', type: 'string' }, @@ -53,3 +58,13 @@ export function createFflogsCharacterSummaryOperation( }, }; } + +function removeEmpty(input: Record) { + return Object.entries(input).reduce>( + (result, [key, value]) => { + if (value !== undefined && value !== '') result[key] = value; + return result; + }, + {}, + ); +} diff --git a/src/modules/qqbot/plugins/fflogs/src/operations/index.ts b/src/modules/qqbot/plugins/fflogs/src/operations/index.ts index eda3ed0..671f32f 100644 --- a/src/modules/qqbot/plugins/fflogs/src/operations/index.ts +++ b/src/modules/qqbot/plugins/fflogs/src/operations/index.ts @@ -29,6 +29,7 @@ export function buildFflogsOperations( } const implementation = factory(); return { + aliases: operation.aliases, cacheTtlMs: operation.cacheTtlMs || implementation.cacheTtlMs, description: operation.description, execute: implementation.execute, @@ -36,6 +37,7 @@ export function buildFflogsOperations( key: operation.key, name: operation.name, outputSchema: operation.outputSchema || implementation.outputSchema, + timeoutMs: operation.timeoutMs, }; }); } diff --git a/src/modules/qqbot/plugins/fflogs/src/operations/operation-manifest.ts b/src/modules/qqbot/plugins/fflogs/src/operations/operation-manifest.ts index 2293066..1ef300d 100644 --- a/src/modules/qqbot/plugins/fflogs/src/operations/operation-manifest.ts +++ b/src/modules/qqbot/plugins/fflogs/src/operations/operation-manifest.ts @@ -1,4 +1,5 @@ export type FflogsManifestOperation = { + aliases?: string[]; cacheTtlMs?: number; description?: string; handlerName: string; @@ -6,6 +7,7 @@ export type FflogsManifestOperation = { key: string; name: string; outputSchema?: Record; + timeoutMs?: number; }; export type FflogsManifest = { diff --git a/src/modules/qqbot/plugins/repeater/src/index.ts b/src/modules/qqbot/plugins/repeater/src/index.ts index 9dabe41..7a2824a 100644 --- a/src/modules/qqbot/plugins/repeater/src/index.ts +++ b/src/modules/qqbot/plugins/repeater/src/index.ts @@ -3,7 +3,7 @@ import type { RepeaterManifest } from './domain/repeater.types'; import { createRepeaterMessageEventHandler } from './events/message'; import type { RepeaterPluginHost } from './infrastructure/integration/repeater-host'; -export type RepeaterPluginOptions = { +type RepeaterPluginOptions = { host: RepeaterPluginHost; manifest: RepeaterManifest; now?: () => number; @@ -30,6 +30,3 @@ export function createPlugin(options: RepeaterPluginOptions) { unbind: (selfId: string) => application.unbind(selfId), }; } - -export type { RepeaterPluginHost } from './infrastructure/integration/repeater-host'; -export type { RepeaterManifest, RepeaterMessage } from './domain/repeater.types'; diff --git a/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts b/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts index ae8e357..51b06c3 100644 --- a/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts @@ -24,7 +24,7 @@ const readManifest = (pluginDir: string) => }, ); -const bangDreamOperations: ExpectedOperation[] = [ +const bangdreamOperations: ExpectedOperation[] = [ { aliases: ['查曲', 'bd', 'bangdream', 'bandori', '邦邦', '邦邦查歌'], handlerName: 'searchSong', @@ -128,13 +128,13 @@ describe('QQBot current operation matrix', () => { key: operation.key, name: operation.name, })), - ).toEqual(bangDreamOperations); + ).toEqual(bangdreamOperations); }); it('freezes built-in plugin manifests and exposed capabilities', () => { const manifests = { bangdream: readManifest('bangdream'), - ff14Market: readManifest('ff14-market'), + ff14: readManifest('ff14-market'), fflogs: readManifest('fflogs'), repeater: readManifest('repeater'), }; @@ -147,10 +147,10 @@ describe('QQBot current operation matrix', () => { key: operation.key, name: operation.name, })), - ).toEqual(bangDreamOperations); + ).toEqual(bangdreamOperations); expect( - manifests.ff14Market.operations.map((operation) => ({ + manifests.ff14.operations.map((operation) => ({ aliases: operation.aliases, handlerName: operation.handlerName, key: operation.key, @@ -208,7 +208,7 @@ describe('QQBot current operation matrix', () => { it('freezes current online command seed linkage for command plugins', () => { const seedSql = readFileSync(join(repoRoot, 'sql/qqbot-init.sql'), 'utf8'); - for (const operation of bangDreamOperations) { + for (const operation of bangdreamOperations) { expect(seedSql).toContain(`'${operation.key}'`); expect(seedSql).toContain(JSON.stringify(operation.aliases)); } diff --git a/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts index 9bb81ec..7869aac 100644 --- a/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-module-boundary.spec.ts @@ -83,10 +83,10 @@ describe('QQBot third-phase module boundaries', () => { 'QqbotPluginHttpClientService', 'QqbotPluginRegistryService', 'QqbotEventPluginRegistryService', - 'QqbotBangDream', - 'QqbotFf14', - 'QqbotFflogs', - 'QqbotRepeater', + `Qqbot${'BangDream'}`, + `Qqbot${'Ff14'}`, + `Qqbot${'Fflogs'}`, + `Qqbot${'Repeater'}`, 'BangDreamApplicationService', ]; @@ -132,6 +132,22 @@ describe('QQBot third-phase module boundaries', () => { expect(violations).toEqual([]); }); + it('keeps NapCat WebUI HTTP clients inside infrastructure integration', () => { + const violations = collectTsFiles(join(qqbotRoot, 'napcat/application')) + .map((filePath) => ({ + file: toRepoPath(filePath), + source: readSource(filePath), + })) + .filter(({ source }) => + /from ['"]https?['"]|import \* as https?|client\.request|http\.request|https\.request|Credential.*expiresAt/.test( + source, + ), + ) + .map(({ file }) => file); + + expect(violations).toEqual([]); + }); + it('has schema ownership notes for each QQBot runtime module', () => { const missing = ['core', 'plugin-platform', 'napcat'] .map((moduleName) => join(qqbotRoot, moduleName, 'schema/README.md')) diff --git a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts index 00e6fcd..41441a5 100644 --- a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts @@ -225,6 +225,47 @@ describe('QQBot plugin package boundary', () => { expect(duplicateMetadataFiles).toEqual([]); }); + it('keeps plugin package entrypoints limited to createPlugin', () => { + const extraExports = [ + 'bangdream', + 'ff14-market', + 'fflogs', + 'repeater', + ].flatMap((pluginKey) => { + const entryPath = join(pluginRoot, pluginKey, 'src/index.ts'); + const source = readFileSync(entryPath, 'utf8'); + return source + .split(/\r?\n/) + .map((line, index) => ({ index: index + 1, line: line.trim() })) + .filter(({ line }) => /^export\s+/.test(line)) + .filter(({ line }) => !/^export\s+function\s+createPlugin\b/.test(line)) + .map( + ({ index, line }) => + `${toRepoPath(entryPath)}:${index} :: ${line}`, + ); + }); + + expect(extraExports).toEqual([]); + }); + + it('does not keep registry-named BangDream runtime files or types', () => { + const bangdreamSourceRoot = join(pluginRoot, 'bangdream', 'src'); + const violations = collectTsFiles(bangdreamSourceRoot).flatMap( + (filePath) => { + const file = toRepoPath(filePath); + const source = readFileSync(filePath, 'utf8'); + return [ + file.includes('.registry.ts') ? `${file} :: registry file` : '', + /\b[A-Za-z0-9]+Registry\b/.test(source) + ? `${file} :: Registry type name` + : '', + ].filter(Boolean); + }, + ); + + expect(violations).toEqual([]); + }); + it('does not keep pure transfer TypeScript files in plugin packages', () => { const transferFiles = collectTsFiles(pluginRoot) .filter((filePath) => { diff --git a/test/modules/qqbot/core/qqbot-core-command-smoke.spec.ts b/test/modules/qqbot/core/qqbot-core-command-smoke.spec.ts index 5afde96..e3dab33 100644 --- a/test/modules/qqbot/core/qqbot-core-command-smoke.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-command-smoke.spec.ts @@ -28,8 +28,8 @@ describe('QQBot core command local smoke', () => { let app: INestApplication; const command = { - aliases: '["查歌"]', - code: 'bangdream-song-search', + aliases: '[]', + code: 'bd', cooldownMs: 5000, defaultParams: null, enabled: true, @@ -37,7 +37,7 @@ describe('QQBot core command local smoke', () => { id: 'cmd-bangdream-song-search', isDeleted: false, lastHitAt: null, - name: 'BangDream 查歌', + name: '', operationKey: 'bangdream.song.search', parserKey: 'plain', pluginKey: 'bangdream', @@ -73,7 +73,9 @@ describe('QQBot core command local smoke', () => { type: 'text', }), getOperationByCommand: jest.fn().mockResolvedValue({ + aliases: ['查歌', 'bd', 'bangdream', 'bandori', '邦邦', '邦邦查歌'], key: command.operationKey, + name: '查曲', pluginKey: command.pluginKey, }), }; diff --git a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts index dba51e6..d42cebb 100644 --- a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts @@ -73,8 +73,6 @@ describe('QQBot core module contract', () => { expect.arrayContaining([ 'GET /qqbot/account/list', 'GET /qqbot/account/enabled', - 'POST /qqbot/account/scan/create', - 'GET /qqbot/account/scan/status', 'POST /qqbot/account/bind/command', 'POST /qqbot/account/unbind/command', 'POST /qqbot/account/kick', @@ -92,6 +90,17 @@ describe('QQBot core module contract', () => { 'GET /qqbot/dashboard/summary', ]), ); + expect(routes.map(routeKey)).not.toEqual( + expect.arrayContaining([ + 'POST /qqbot/account/scan/create', + 'POST /qqbot/account/scan/refresh', + 'GET /qqbot/account/scan/status', + 'GET /qqbot/account/scan/events', + 'POST /qqbot/account/scan/qrcode/refresh', + 'POST /qqbot/account/scan/captcha/submit', + 'POST /qqbot/account/scan/cancel', + ]), + ); }); it('routes QQBot through the core module as the owning Nest boundary', () => { @@ -159,10 +168,6 @@ describe('QQBot core module contract', () => { 'QqbotDedupeService', 'QqbotEventService', 'QqbotMessageService', - 'NapcatDeviceIdentityService', - 'QqbotNapcatContainerService', - 'QqbotNapcatLoginService', - 'QqbotNapcatWatchdogService', 'QqbotPermissionService', 'QqbotRateLimitService', 'QqbotReplyTemplateService', @@ -172,25 +177,43 @@ describe('QQBot core module contract', () => { 'QqbotSendService', ]), ); + expect(getNames(QQBOT_CORE_PROVIDERS)).not.toEqual( + expect.arrayContaining([ + 'NapcatDeviceIdentityService', + 'NapcatLoginStateStoreService', + 'QqbotNapcatContainerService', + 'QqbotNapcatLoginService', + 'QqbotNapcatWatchdogService', + ]), + ); expect(getNames(QQBOT_CORE_ENTITIES)).toEqual( expect.arrayContaining([ 'QqbotAccount', 'QqbotAccountAbility', - 'QqbotAccountNapcat', 'QqbotAllowlist', 'QqbotBlocklist', - 'NapcatDeviceIdentity', 'QqbotCommand', 'QqbotCommandLog', 'QqbotConfig', 'QqbotConversation', 'QqbotDedupe', 'QqbotMessage', - 'QqbotNapcatContainer', 'QqbotRule', 'QqbotSendLog', ]), ); + expect(getNames(QQBOT_CORE_ENTITIES)).not.toEqual( + expect.arrayContaining([ + 'NapcatAccountBinding', + 'NapcatContainer', + 'NapcatDeviceIdentity', + 'NapcatLoginChallengeEntity', + 'NapcatLoginSession', + 'NapcatRuntimeCleanup', + 'QqbotAccountNapcat', + 'QqbotNapcatContainer', + ]), + ); }); it('keeps every registered QQBot core entity mapped to the refactor-v3 schema', () => { diff --git a/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts b/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts index 429b988..cfed715 100644 --- a/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-plugin-ports.spec.ts @@ -45,7 +45,7 @@ describe('QQBot core plugin execution ports', () => { ); const bannedRepeaterSignals = [ - 'QqbotRepeaterPluginService', + `Qqbot${'RepeaterPluginService'}`, '@/modules/qqbot/plugins/repeater', ].filter((signal) => source.includes(signal)); const missingDispatcherSignals = ['dispatchEvent'].filter( diff --git a/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts b/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts new file mode 100644 index 0000000..ef04655 --- /dev/null +++ b/test/modules/qqbot/napcat/napcat-module-ownership.spec.ts @@ -0,0 +1,164 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { getMetadataArgsStorage } from 'typeorm'; +import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module'; +import { AppModule } from '../../../../src/app.module'; +import { QqbotCoreModule } from '../../../../src/modules/qqbot/core/qqbot-core.module'; +import { + QQBOT_NAPCAT_CONTROLLERS, + QQBOT_NAPCAT_ENTITIES, + QQBOT_NAPCAT_EXPORTS, + QQBOT_NAPCAT_PROVIDERS, + QqbotNapcatModule, +} from '../../../../src/modules/qqbot/napcat/qqbot-napcat.module'; +import { QqbotNapcatLoginController } from '../../../../src/modules/qqbot/napcat/contract/qqbot-napcat-login.controller'; +import { + collectControllerRoutes, + routeKey, +} from '../../../helpers/controller-route.helper'; +import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; + +const getModuleMetadata = (moduleClass: unknown, key: string): T[] => { + return Reflect.getMetadata(key, moduleClass) || []; +}; + +const getNames = (items: unknown[]) => + items.map((item) => (item as { name?: string }).name || `${item}`); + +const unwrapForwardRef = (item: unknown) => { + const maybeForwardRef = item as { forwardRef?: () => unknown }; + return typeof maybeForwardRef.forwardRef === 'function' + ? maybeForwardRef.forwardRef() + : item; +}; + +type EntityClass = new (...args: never[]) => unknown; + +const getEntityTableName = (entity: EntityClass) => { + return getMetadataArgsStorage().tables.find( + (table) => table.target === entity, + )?.name; +}; + +describe('QQBot NapCat module ownership', () => { + const schema = readRefactorV3SqlSchema(); + + it('owns scan, captcha, QR refresh and SSE routes under the NapCat boundary', () => { + const routes = collectControllerRoutes(QQBOT_NAPCAT_CONTROLLERS); + + expect(QQBOT_NAPCAT_CONTROLLERS).toEqual( + expect.arrayContaining([QqbotNapcatLoginController]), + ); + expect(routes.map(routeKey)).toEqual( + expect.arrayContaining([ + 'POST /qqbot/account/scan/create', + 'POST /qqbot/account/scan/refresh', + 'GET /qqbot/account/scan/status', + 'GET /qqbot/account/scan/events', + 'POST /qqbot/account/scan/qrcode/refresh', + 'POST /qqbot/account/scan/captcha/submit', + 'POST /qqbot/account/scan/cancel', + ]), + ); + }); + + it('is imported as its own Nest module and keeps contract files in napcat/contract', () => { + const contractControllerPath = join( + process.cwd(), + 'src', + 'modules', + 'qqbot', + 'napcat', + 'contract', + 'qqbot-napcat-login.controller.ts', + ); + const appImports = getModuleMetadata(AppModule, MODULE_METADATA.IMPORTS); + const napcatImports = getModuleMetadata( + QqbotNapcatModule, + MODULE_METADATA.IMPORTS, + ).map(unwrapForwardRef); + + expect(existsSync(contractControllerPath)).toBe(true); + expect(appImports).toEqual(expect.arrayContaining([QqbotNapcatModule])); + expect(napcatImports).toEqual( + expect.arrayContaining([ + ConfigModule, + AdminAuthGuardModule, + QqbotCoreModule, + ]), + ); + expect( + napcatImports.some( + (item) => (item as { module?: unknown }).module === TypeOrmModule, + ), + ).toBe(true); + }); + + it('registers NapCat runtime providers and entities outside QQBot core', () => { + expect(getNames(QQBOT_NAPCAT_PROVIDERS)).toEqual( + expect.arrayContaining([ + 'NapcatDeviceIdentityService', + 'NapcatLoginStateStoreService', + 'QqbotNapcatContainerService', + 'QqbotNapcatLoginService', + 'QqbotNapcatWatchdogService', + ]), + ); + expect(getNames(QQBOT_NAPCAT_ENTITIES)).toEqual( + expect.arrayContaining([ + 'NapcatAccountBinding', + 'NapcatContainer', + 'NapcatDeviceIdentity', + 'NapcatLoginChallengeEntity', + 'NapcatLoginSession', + 'NapcatRuntimeCleanup', + ]), + ); + expect(getNames(QQBOT_NAPCAT_ENTITIES)).not.toEqual( + expect.arrayContaining(['QqbotAccountNapcat', 'QqbotNapcatContainer']), + ); + expect(getNames(QQBOT_NAPCAT_EXPORTS)).toEqual( + expect.arrayContaining([ + 'NapcatDeviceIdentityService', + 'NapcatLoginStateStoreService', + 'QqbotNapcatContainerService', + 'QqbotNapcatLoginService', + ]), + ); + }); + + it('keeps every registered NapCat entity mapped to the refactor-v3 schema', () => { + for (const entity of QQBOT_NAPCAT_ENTITIES) { + const tableName = getEntityTableName(entity); + + expect(tableName).toBeTruthy(); + expect(schema.hasTable(tableName || '')).toBe(true); + } + }); + + it('does not keep legacy qqbot_napcat tables as executable schema truth', () => { + const schemaSql = readFileSync( + join(process.cwd(), 'sql', 'refactor-v3', '00-full-schema.sql'), + 'utf8', + ); + const qqbotInitSql = readFileSync( + join(process.cwd(), 'sql', 'qqbot-init.sql'), + 'utf8', + ); + + expect(schemaSql).not.toContain('CREATE TABLE IF NOT EXISTS qqbot_account_napcat'); + expect(schemaSql).not.toContain('CREATE TABLE IF NOT EXISTS qqbot_napcat_container'); + expect(qqbotInitSql).not.toContain('qqbot_account_napcat'); + expect(qqbotInitSql).not.toContain('qqbot_napcat_container'); + expect(qqbotInitSql).toContain('CREATE TABLE IF NOT EXISTS `napcat_account_binding`'); + expect(qqbotInitSql).toContain('CREATE TABLE IF NOT EXISTS `napcat_container`'); + expect( + getNames(QQBOT_NAPCAT_ENTITIES).filter((name) => + name.startsWith('Qqbot'), + ), + ).toEqual([]); + }); +}); diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index 6303b57..0c7343b 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -65,11 +65,11 @@ describe('NapCat persistent login state contract', () => { const sqlSchema = readSource('sql/refactor-v3/00-full-schema.sql'); const persistenceFiles = existsSync(persistenceRoot) ? readSource( - 'src/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity.ts', + 'src/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity.ts', ) + '\n' + readSource( - 'src/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.entity.ts', + 'src/modules/qqbot/napcat/infrastructure/persistence/napcat-container.entity.ts', ) : ''; const source = `${sqlSchema}\n${persistenceFiles}`; @@ -208,6 +208,166 @@ describe('NapCat persistent login state contract', () => { ); }); + it('recovers captcha, new-device, and cleanup blockers after cache miss', async () => { + const loginSessionRepository = createRepository(); + const loginChallengeRepository = + createRepository(); + const runtimeCleanupRepository = createRepository(); + const store = new NapcatLoginStateStoreService( + loginSessionRepository as any, + loginChallengeRepository as any, + runtimeCleanupRepository as any, + ); + + loginSessionRepository.rows.push({ + expiresAt: new Date(Date.now() + 60_000), + sessionKey: 'captcha-recover', + sessionPayload: { + containerId: 'container-captcha', + containerName: 'kt-qqbot-napcat-captcha', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'captcha-recover', + mode: 'refresh', + passwordMd5: 'md5', + status: 'pending', + webuiPort: 6099, + }, + status: 'pending', + } as any); + loginChallengeRepository.rows.push({ + challengePayload: { expectedSelfId: '10001' }, + challengeType: 'captcha', + challengeUrl: 'https://captcha.example/proof', + sessionId: 'captcha-recover', + status: 'pending', + } as any); + + loginSessionRepository.rows.push({ + expiresAt: new Date(Date.now() + 60_000), + sessionKey: 'new-device-recover', + sessionPayload: { + containerId: 'container-device', + containerName: 'kt-qqbot-napcat-device', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'new-device-recover', + mode: 'refresh', + status: 'pending', + webuiPort: 6099, + }, + status: 'pending', + } as any); + loginChallengeRepository.rows.push({ + challengePayload: { + deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', + newDevicePullQrCodeSig: 'sig-new-device', + newDeviceQrcode: 'data:image/png;base64,new-device-qrcode', + }, + challengeType: 'new-device', + challengeUrl: 'data:image/png;base64,new-device-qrcode', + sessionId: 'new-device-recover', + status: 'qr-pending', + } as any); + + loginSessionRepository.rows.push({ + expiresAt: new Date(Date.now() + 60_000), + sessionKey: 'cleanup-recover', + sessionPayload: { + containerId: 'container-cleanup', + containerName: 'kt-qqbot-napcat-cleanup', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'cleanup-recover', + mode: 'refresh', + passwordMd5: 'md5', + status: 'pending', + webuiPort: 6099, + }, + status: 'pending', + } as any); + runtimeCleanupRepository.rows.push({ + cleanupType: 'password-login-env', + errorMessage: '运行态密码清理失败', + sessionId: 'cleanup-recover', + status: 'failed', + } as any); + + await expect(store.get('captcha-recover')).resolves.toEqual( + expect.objectContaining({ + captchaUrl: 'https://captcha.example/proof', + expectedSelfId: '10001', + id: 'captcha-recover', + status: 'pending', + }), + ); + await expect(store.get('new-device-recover')).resolves.toEqual( + expect.objectContaining({ + deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', + newDevicePullQrCodeSig: 'sig-new-device', + newDeviceQrcode: 'data:image/png;base64,new-device-qrcode', + newDeviceStatus: 'qr-pending', + }), + ); + await expect(store.get('cleanup-recover')).resolves.toEqual( + expect.objectContaining({ + errorMessage: '运行态密码清理失败', + passwordMd5: undefined, + status: 'error', + }), + ); + }); + + it('does not recover resolved new-device challenges as actionable QR state', async () => { + const loginSessionRepository = createRepository(); + const loginChallengeRepository = + createRepository(); + const store = new NapcatLoginStateStoreService( + loginSessionRepository as any, + loginChallengeRepository as any, + ); + + loginSessionRepository.rows.push({ + expiresAt: new Date(Date.now() + 60_000), + sessionKey: 'new-device-failed', + sessionPayload: { + containerId: 'container-device', + containerName: 'kt-qqbot-napcat-device', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'new-device-failed', + mode: 'refresh', + status: 'pending', + webuiPort: 6099, + }, + status: 'pending', + } as any); + loginChallengeRepository.rows.push({ + challengePayload: { + deviceVerifyUrl: 'https://ti.qq.com/new-device/verify', + newDevicePullQrCodeSig: 'stale-sig', + newDeviceQrcode: 'data:image/png;base64,stale-qrcode', + }, + challengeType: 'new-device', + challengeUrl: 'data:image/png;base64,stale-qrcode', + sessionId: 'new-device-failed', + status: 'failed', + } as any); + + const recovered = await store.get('new-device-failed'); + + expect(recovered).toEqual( + expect.objectContaining({ + id: 'new-device-failed', + status: 'pending', + }), + ); + expect(recovered?.deviceVerifyUrl).toBeUndefined(); + expect(recovered?.newDevicePullQrCodeSig).toBeUndefined(); + expect(recovered?.newDeviceQrcode).toBeUndefined(); + expect(recovered?.newDeviceStatus).toBeUndefined(); + }); + it('keeps captcha and new-device challenges recoverable and separate', () => { const stateMachine = readSource( 'src/modules/qqbot/napcat/domain/login/napcat-login-state-machine.ts', diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index f82e258..e465130 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -1,13 +1,58 @@ -import { readFileSync } from 'fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { + QQBOT_PLUGIN_RUNTIME_FACTORY, +} from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; +import { QqbotEventPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service'; +import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service'; +import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module'; +import { QqbotBuiltinPluginPackageLoaderService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; +import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/runtime'; const repoRoot = join(__dirname, '../../../..'); const readSource = (relativePath: string) => readFileSync(join(repoRoot, relativePath), 'utf8'); +const collectSourceFiles = (relativePath: string): string[] => { + const absolutePath = join(repoRoot, relativePath); + if (!existsSync(absolutePath)) return []; + const stat = statSync(absolutePath); + if (stat.isFile()) return [absolutePath]; + return readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) => + collectSourceFiles(join(relativePath, entry.name)), + ); +}; + describe('QQBot plugin platform lifecycle runtime contract', () => { + it('registers the default worker runtime factory in the Nest module', () => { + const providers = Reflect.getMetadata( + MODULE_METADATA.PROVIDERS, + QqbotPluginPlatformModule, + ) as unknown[]; + + expect(providers).toEqual( + expect.arrayContaining([ + QqbotBuiltinPluginWorkerRuntimeFactoryService, + expect.objectContaining({ + provide: QQBOT_PLUGIN_RUNTIME_FACTORY, + useExisting: QqbotBuiltinPluginWorkerRuntimeFactoryService, + }), + ]), + ); + }); + + it('keeps the default worker runtime factory dependency visible to Nest DI', () => { + const dependencies = Reflect.getMetadata( + 'design:paramtypes', + QqbotBuiltinPluginWorkerRuntimeFactoryService, + ); + + expect(dependencies?.[0]).toBe(QqbotBuiltinPluginPackageLoaderService); + }); + it('uses dedicated lifecycle use cases instead of direct status flips', () => { const controller = readSource( 'src/modules/qqbot/plugin-platform/contract/plugin-platform.controller.ts', @@ -69,6 +114,36 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(missingExecutorSignals).toEqual([]); }); + it('keeps event plugin dispatch generic instead of hard-coding repeater', () => { + const source = readSource( + 'src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts', + ); + + expect(source).toContain('registerEventPlugin'); + expect(source).not.toMatch(/pluginKey\s*===\s*['"]repeater['"]/); + expect(source).not.toContain('getRepeaterPlugin'); + }); + + it('keeps plugin-specific argument parsing inside plugin packages', () => { + const sources = collectSourceFiles( + 'src/modules/qqbot/plugin-platform/application/argument', + ) + .concat( + collectSourceFiles( + 'src/modules/qqbot/plugin-platform/infrastructure/integration/argument', + ), + ) + .map((file) => readFileSync(file, 'utf8')) + .join('\n'); + + expect(sources).not.toMatch(/DictService/); + expect(sources).not.toMatch(/modules\/qqbot\/plugins\/ff14-market/); + expect(sources).not.toMatch(/modules\/qqbot\/plugins\/fflogs/); + expect(sources).not.toMatch( + new RegExp([`parseQqbot${'Ff14'}`, `parseQqbot${'Fflogs'}`].join('|')), + ); + }); + it('loads, activates, health-checks, refreshes, and disposes workers during lifecycle transitions', async () => { const manifest = { entry: 'src/index.ts', @@ -97,7 +172,10 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { save: jest.fn(async (value) => value), update: jest.fn(async () => ({ affected: 1 })), }); - const pluginRepository = createRepository(); + const pluginRepository = createRepository({ + id: installation.pluginId, + pluginKey: 'demo-plugin', + }); const versionRepository = createRepository(version); const installationRepository = createRepository(installation); const operationRepository = createRepository(); @@ -116,6 +194,12 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { const runtimeFactory = { create: jest.fn(() => worker), }; + const pluginRegistry = { + setPluginActive: jest.fn(), + }; + const eventPluginRegistry = { + setPluginActive: jest.fn(), + }; const service = new (QqbotPluginPlatformService as any)( pluginRepository, versionRepository, @@ -128,6 +212,8 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { runtimeEventRepository, undefined, runtimeFactory, + pluginRegistry, + eventPluginRegistry, ) as QqbotPluginPlatformService; await expect( @@ -149,6 +235,14 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { { pluginId: installation.pluginId }, { enabled: true }, ); + expect(pluginRegistry.setPluginActive).toHaveBeenCalledWith( + 'demo-plugin', + true, + ); + expect(eventPluginRegistry.setPluginActive).toHaveBeenCalledWith( + 'demo-plugin', + true, + ); expect(runtimeEventRepository.save).toHaveBeenCalledWith( expect.objectContaining({ eventType: 'enable-finished', @@ -158,10 +252,178 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { ); await service.disableInstallation({ id: installation.id }); + expect(pluginRegistry.setPluginActive).toHaveBeenLastCalledWith( + 'demo-plugin', + false, + ); + expect(eventPluginRegistry.setPluginActive).toHaveBeenLastCalledWith( + 'demo-plugin', + false, + ); expect(worker.deactivate).toHaveBeenCalled(); expect(worker.dispose).toHaveBeenCalled(); }); + it('blocks command execution and event dispatch for inactive plugins', async () => { + const commandPlugin = { + key: 'demo-plugin', + name: 'Demo Plugin', + operations: [ + { + execute: jest.fn(async () => ({ replyText: 'ok' })), + key: 'demo.echo', + name: 'echo', + }, + ], + version: '0.1.0', + }; + const commandRegistry = new QqbotPluginRegistryService({ + loadCommandPlugins: () => [commandPlugin], + } as any); + commandRegistry.onModuleInit(); + + await expect( + commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}), + ).resolves.toEqual({ replyText: 'ok' }); + + commandRegistry.setPluginActive('demo-plugin', false); + + expect(commandRegistry.listOperations('demo-plugin')).toEqual([]); + await expect( + commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 插件未启用:demo-plugin', + }), + }); + expect(commandPlugin.operations[0].execute).toHaveBeenCalledTimes(1); + + const repeaterPlugin = { + bind: jest.fn(async () => true), + getDefinition: () => ({ + description: 'repeat messages', + key: 'repeater', + name: 'Repeater', + triggerType: 'message', + version: '0.1.0', + }), + getSummary: jest.fn(), + handleMessage: jest.fn(async () => true), + unbind: jest.fn(async () => true), + }; + const eventRegistry = new QqbotEventPluginRegistryService( + { + allEnabled: jest.fn(async () => []), + findBySelfId: jest.fn(), + } as any, + { + loadEventPlugins: () => [repeaterPlugin], + } as any, + ); + + await expect( + eventRegistry.dispatchMessage({ messageType: 'group' } as any), + ).resolves.toBe(true); + + eventRegistry.setPluginActive('repeater', false); + + expect(eventRegistry.listOperations('repeater')).toEqual([]); + await expect( + eventRegistry.dispatchMessage({ messageType: 'group' } as any), + ).resolves.toBe(false); + await expect(eventRegistry.bind('repeater', '10000')).rejects.toMatchObject( + { + response: expect.objectContaining({ + msg: 'QQBot 事件插件未启用:repeater', + }), + }, + ); + expect(repeaterPlugin.handleMessage).toHaveBeenCalledTimes(1); + }); + + it('hydrates inactive command and event plugin keys from persisted installation state on startup', async () => { + const commandPlugin = { + key: 'demo-plugin', + name: 'Demo Plugin', + operations: [ + { + execute: jest.fn(async () => ({ replyText: 'ok' })), + key: 'demo.echo', + name: 'echo', + }, + ], + version: '0.1.0', + }; + const createRepository = (rows: unknown[] = []) => ({ + find: jest.fn(async () => rows), + }); + const pluginRepository = createRepository([ + { id: 'plugin-command', pluginKey: 'demo-plugin' }, + { id: 'plugin-event', pluginKey: 'repeater' }, + ]); + const installationRepository = createRepository([ + { pluginId: 'plugin-command', status: 'disabled' }, + { pluginId: 'plugin-event', status: 'disabled' }, + ]); + const commandRegistry = new (QqbotPluginRegistryService as any)( + { + loadCommandPlugins: () => [commandPlugin], + }, + pluginRepository, + installationRepository, + ) as QqbotPluginRegistryService; + + await commandRegistry.onModuleInit(); + + expect(commandRegistry.listOperations('demo-plugin')).toEqual([]); + await expect( + commandRegistry.execute('demo-plugin', 'demo.echo', {}, {}), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 插件未启用:demo-plugin', + }), + }); + + const repeaterPlugin = { + bind: jest.fn(async () => true), + getDefinition: () => ({ + description: 'repeat messages', + key: 'repeater', + name: 'Repeater', + triggerType: 'message', + version: '0.1.0', + }), + getSummary: jest.fn(), + handleMessage: jest.fn(async () => true), + unbind: jest.fn(async () => true), + }; + const eventRegistry = new (QqbotEventPluginRegistryService as any)( + { + allEnabled: jest.fn(async () => []), + findBySelfId: jest.fn(), + }, + { + loadEventPlugins: () => [repeaterPlugin], + }, + pluginRepository, + installationRepository, + ) as QqbotEventPluginRegistryService; + + await eventRegistry.onModuleInit(); + + expect(eventRegistry.listOperations('repeater')).toEqual([]); + await expect( + eventRegistry.dispatchMessage({ messageType: 'group' } as any), + ).resolves.toBe(false); + await expect(eventRegistry.bind('repeater', '10000')).rejects.toMatchObject( + { + response: expect.objectContaining({ + msg: 'QQBot 事件插件未启用:repeater', + }), + }, + ); + }); + it('keeps the previous active worker when upgrade health check fails', async () => { const installation = { id: 'installation-upgrade', diff --git a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts index af14f3c..5d40e49 100644 --- a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'fs'; +import { createHash } from 'crypto'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; @@ -8,6 +9,7 @@ import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-au import { QqbotPluginPlatformController } from '../../../../src/modules/qqbot/plugin-platform/contract/plugin-platform.controller'; import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module'; import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/application/plugin-platform.service'; +import { QqbotPluginPackageReaderService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-reader.service'; import { QQBOT_PLUGIN_PLATFORM_ENTITIES, QqbotPlugin, @@ -61,6 +63,55 @@ const createManifest = () => ({ version: '0.1.0', }); +const stableStringify = (value: unknown): string => { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +}; + +const sha256 = (content: Buffer | string) => + createHash('sha256').update(content).digest('hex'); + +const packageRoot = join( + process.cwd(), + '.kt-workspace', + 'qqbot-plugin-packages', + 'api-contract', +); + +const writePluginPackage = (manifest: ReturnType) => { + mkdirSync(packageRoot, { recursive: true }); + const packageBody = { + contentHash: '', + files: [], + manifest, + }; + packageBody.contentHash = sha256( + stableStringify({ + files: packageBody.files, + manifest: packageBody.manifest, + }), + ); + const packagePath = join( + packageRoot, + `${manifest.pluginKey}-${manifest.version}.qqbot-plugin.json`, + ); + writeFileSync(packagePath, `${JSON.stringify(packageBody, null, 2)}\n`); + return { + packageHash: packageBody.contentHash, + packagePath, + }; +}; + describe('QQBot plugin platform API contract', () => { let app: INestApplication; let repositoryMocks: Map>; @@ -71,6 +122,7 @@ describe('QQBot plugin platform API contract', () => { controllers: [QqbotPluginPlatformController], providers: [ QqbotPluginPlatformService, + QqbotPluginPackageReaderService, ...[ QqbotPlugin, QqbotPluginVersion, @@ -101,6 +153,7 @@ describe('QQBot plugin platform API contract', () => { afterEach(async () => { await app.close(); + rmSync(packageRoot, { force: true, recursive: true }); }); it('registers plugin-platform as a first-class AppModule import', () => { @@ -169,6 +222,55 @@ describe('QQBot plugin platform API contract', () => { }); }); + it('validates controlled plugin packages during upload and install', async () => { + const manifest = createManifest(); + const { packageHash, packagePath } = writePluginPackage(manifest); + + const uploadResponse = await request(app.getHttpServer()) + .post('/qqbot/plugin-platform/upload') + .send({ packagePath }) + .expect(200); + + expect(uploadResponse.body).toMatchObject({ + code: 200, + data: { + manifest: { + pluginKey: manifest.pluginKey, + }, + packageHash, + packagePath, + valid: true, + }, + }); + + await request(app.getHttpServer()) + .post('/qqbot/plugin-platform/install-local') + .send({ packageHash, packagePath }) + .expect(200); + + expect(repositoryMocks.get(QqbotPluginVersion)?.save).toHaveBeenCalledWith( + expect.objectContaining({ + packageHash, + }), + ); + expect( + repositoryMocks.get(QqbotPluginInstallation)?.save, + ).toHaveBeenCalledWith( + expect.objectContaining({ + installedPath: packagePath, + }), + ); + + await request(app.getHttpServer()) + .post('/qqbot/plugin-platform/install-local') + .send({ packageHash: 'bad-hash', packagePath }) + .expect(400); + await request(app.getHttpServer()) + .post('/qqbot/plugin-platform/install-local') + .send({ packagePath: join(process.cwd(), 'package.json') }) + .expect(400); + }); + it('keeps TypeORM entity registration aligned with the persistence contract', () => { expect(QqbotPluginPlatformModule).toBeDefined(); expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9); diff --git a/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts b/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts new file mode 100644 index 0000000..b592cc9 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-registry-timeout.spec.ts @@ -0,0 +1,39 @@ +import { QqbotPluginRegistryService } from '../../../../src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service'; + +describe('QQBot plugin registry operation timeout', () => { + it('enforces manifest operation timeout for direct built-in command execution', async () => { + const commandRegistry = new QqbotPluginRegistryService({ + loadCommandPlugins: () => [ + { + key: 'demo-plugin', + name: 'Demo Plugin', + operations: [ + { + execute: jest.fn( + () => new Promise((resolve) => setTimeout(resolve, 1000)), + ), + key: 'demo.slow', + name: 'slow', + timeoutMs: 5, + }, + ], + version: '0.1.0', + }, + ], + } as any); + commandRegistry.onModuleInit(); + + await expect( + Promise.race([ + commandRegistry.execute('demo-plugin', 'demo.slow', {}, {}), + new Promise((_, reject) => + setTimeout(() => reject(new Error('test timeout waiting')), 50), + ), + ]), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 插件能力执行超时:demo.slow', + }), + }); + }); +}); diff --git a/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts b/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts index f4dcba7..1d0e4fc 100644 --- a/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts +++ b/test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts @@ -35,7 +35,9 @@ describe('BangDream rewritten plugin parity', () => { 'plugin.json', 'src/index.ts', 'src/application/bangdream-command-context.ts', - 'src/application/hook/hook-registry.ts', + 'src/application/catalog/bangdream-catalog-cache.ts', + 'src/application/catalog/bangdream-catalog-repository.ts', + 'src/application/execution/operation-lifecycle.ts', 'src/operations/song-search', 'src/operations/song-chart', 'src/operations/song-random', @@ -59,9 +61,10 @@ describe('BangDream rewritten plugin parity', () => { 'src/domain/player', 'src/domain/cutoff', 'src/domain/catalog', - 'src/domain/common/qqbot-bangdream.types.ts', + 'src/domain/common/bangdream.types.ts', 'src/infrastructure/integration', 'src/infrastructure/storage', + 'src/infrastructure/storage/remote-resource.client.ts', 'src/config', 'src/config/dictionary', 'src/assets', @@ -147,17 +150,48 @@ describe('BangDream rewritten plugin parity', () => { ).toBe(false); } - for (const removedPath of [ + const removedPaths = [ + 'src/application/hook/hook-registry.ts', + 'src/application/main-data-store.ts', + 'src/application/main-data.repository.ts', 'src/application/bangdream-renderer.facade.ts', 'src/application/bangdream-application.service.ts', 'src/application/bangdream-context.ts', 'src/application/bangdream-operation-runtime.ts', 'src/application/operation-pipeline.ts', + 'src/infrastructure/storage/file-cache.client.ts', 'src/operations/operation-executor.ts', 'src/domain/common/bangdream-constants.ts', - 'src/qqbot-bangdream.types.ts', - ]) { - expect(existsSync(join(newBangDreamRoot, removedPath))).toBe(false); - } + `src/qqbot-${'bangdream'}.types.ts`, + ]; + const existingRemovedPaths = removedPaths.filter((removedPath) => + existsSync(join(newBangDreamRoot, removedPath)), + ); + + expect(existingRemovedPaths).toEqual([]); + }); + + it('does not reference forbidden old BangDream bucket names from runtime source', () => { + const forbiddenFragments = [ + 'application/hook/hook-registry', + 'application/main-data-store', + 'application/main-data.repository', + 'infrastructure/storage/file-cache.client', + ]; + const runtimeFiles = [ + 'src/index.ts', + 'src/application/bangdream-command-context.ts', + 'src/infrastructure/storage/asset-cache.client.ts', + 'src/infrastructure/integration/api-cache.client.ts', + ]; + + const violations = runtimeFiles.flatMap((runtimeFile) => { + const source = readFileSync(join(newBangDreamRoot, runtimeFile), 'utf8'); + return forbiddenFragments + .filter((fragment) => source.includes(fragment)) + .map((fragment) => `${runtimeFile} -> ${fragment}`); + }); + + expect(violations).toEqual([]); }); }); diff --git a/test/modules/qqbot/plugins/plugin-input-parser.spec.ts b/test/modules/qqbot/plugins/plugin-input-parser.spec.ts index fcde71d..87d84b9 100644 --- a/test/modules/qqbot/plugins/plugin-input-parser.spec.ts +++ b/test/modules/qqbot/plugins/plugin-input-parser.spec.ts @@ -1,5 +1,5 @@ -import { parseQqbotFf14MarketPriceInput } from '../../../../src/modules/qqbot/plugins/ff14-market/src'; -import { parseQqbotFflogsCharacterInput } from '../../../../src/modules/qqbot/plugins/fflogs/src'; +import { parseFf14MarketPriceInput } from '../../../../src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser'; +import { parseFflogsCharacterInput } from '../../../../src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser'; const ff14Catalog = { dataCenters: [ @@ -16,7 +16,7 @@ const ff14Catalog = { describe('QQBot built-in plugin input parsers', () => { it('keeps FF14 market price parsing inside the FF14 plugin package', () => { expect( - parseQqbotFf14MarketPriceInput('柔韧鲫鱼 猫小胖 宇宙和音 hq', ff14Catalog), + parseFf14MarketPriceInput('柔韧鲫鱼 猫小胖 宇宙和音 hq', ff14Catalog), ).toMatchObject({ dataCenter: '猫小胖', hq: true, @@ -30,7 +30,7 @@ describe('QQBot built-in plugin input parsers', () => { it('keeps FFLogs character parsing inside the FFLogs plugin package', () => { expect( - parseQqbotFflogsCharacterInput('Kwi 宇宙和音 阿尔卡迪亚', { + parseFflogsCharacterInput('Kwi 宇宙和音 阿尔卡迪亚', { resolveKnownWorld: (value) => value === '宇宙和音' ? { serverSlug: value } : null, }), diff --git a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts index 890dfed..2bac09e 100644 --- a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts +++ b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts @@ -21,12 +21,12 @@ const createPlugin = ( describe('QQBot plugin registry platform key compatibility', () => { it('uses platform plugin keys as primary keys while resolving legacy command keys', async () => { const bangdream = createPlugin('bangdream', ['bangDream']); - const ff14Market = createPlugin('ff14-market', ['ff14Market']); + const ff14Plugin = createPlugin('ff14-market', ['ff14Market']); const fflogs = createPlugin('fflogs', []); const registry = new QqbotPluginRegistryService(); registry.register(bangdream); - registry.register(ff14Market); + registry.register(ff14Plugin); registry.register(fflogs); expect(registry.listPlugins().map((plugin) => plugin.key)).toEqual([ diff --git a/test/qqbot/account/qqbot-account.controller.spec.ts b/test/qqbot/account/qqbot-account.controller.spec.ts index c42d856..5694665 100644 --- a/test/qqbot/account/qqbot-account.controller.spec.ts +++ b/test/qqbot/account/qqbot-account.controller.spec.ts @@ -21,6 +21,7 @@ import type { INestApplication } from '@nestjs/common'; import { QqbotAccountController } from '@/modules/qqbot/core/contract/account/qqbot-account.controller'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/login/qqbot-napcat-login.service'; +import { QqbotNapcatLoginController } from '@/modules/qqbot/napcat/contract/qqbot-napcat-login.controller'; import { QqbotReverseWsService } from '@/modules/qqbot/core/infrastructure/integration/connection/qqbot-reverse-ws.service'; describe('QqbotAccountController', () => { @@ -37,7 +38,7 @@ describe('QqbotAccountController', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - controllers: [QqbotAccountController], + controllers: [QqbotAccountController, QqbotNapcatLoginController], providers: [ { provide: QqbotAccountService, useValue: accountService }, { provide: QqbotNapcatLoginService, useValue: napcatLoginService }, diff --git a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts index 833a0a9..c6c9706 100644 --- a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts +++ b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts @@ -1,7 +1,7 @@ -jest.mock('@/modules/qqbot/plugins/bangdream/src/application/main-data-store', () => ({ +jest.mock('@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache', () => ({ __esModule: true, default: {}, - waitForMainDataReady: jest.fn().mockResolvedValue(undefined), + waitForBangDreamCatalogReady: jest.fn().mockResolvedValue(undefined), })); const mockContext = { @@ -59,15 +59,14 @@ jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({ ), })); -import { - createPlugin, - type BangDreamManifestOperation, -} from '@/modules/qqbot/plugins/bangdream/src'; +import { createPlugin } from '@/modules/qqbot/plugins/bangdream/src'; import { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context'; -import { waitForMainDataReady } from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; +import { waitForBangDreamCatalogReady } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'; -const manifestOperations: BangDreamManifestOperation[] = - mockManifestOperations.map(([key, handlerName]) => ({ handlerName, key })); +const manifestOperations = mockManifestOperations.map(([key, handlerName]) => ({ + handlerName, + key, +})); describe('BangDream package entry', () => { beforeEach(() => { @@ -110,7 +109,7 @@ describe('BangDream package entry', () => { }); expect(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1); - expect(waitForMainDataReady).toHaveBeenCalledTimes(1); + expect(waitForBangDreamCatalogReady).toHaveBeenCalledTimes(1); expect(songSearch).toHaveBeenCalledWith( { text: '夏祭り' }, mockContext, diff --git a/test/qqbot/plugins/bangdream/cutoff/cutoff-detail-operation.spec.ts b/test/qqbot/plugins/bangdream/cutoff/cutoff-detail-operation.spec.ts new file mode 100644 index 0000000..0ce6b7e --- /dev/null +++ b/test/qqbot/plugins/bangdream/cutoff/cutoff-detail-operation.spec.ts @@ -0,0 +1,77 @@ +jest.mock( + '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer', + () => ({ + drawCutoffDetail: jest.fn().mockResolvedValue([Buffer.from('cutoff')]), + }), +); + +jest.mock( + '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.renderer', + () => ({ + drawCutoffEventTop: jest.fn().mockResolvedValue([Buffer.from('top')]), + }), +); + +jest.mock('@/modules/qqbot/plugins/bangdream/src/domain/event/event.model', () => ({ + getPresentEvent: jest.fn(() => ({ eventId: 321 })), +})); + +import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer'; +import { cutoffDetailOperation } from '@/modules/qqbot/plugins/bangdream/src/operations/cutoff-detail'; + +const createContext = () => + ({ + firstNumber: jest.fn((tokens: string[]) => + tokens + .map((item) => Number(item)) + .find((item) => Number.isInteger(item)), + ), + getRenderOptions: jest.fn(() => ({ compress: true })), + getTokens: jest.fn((input: { text?: string }) => + `${input.text || ''}`.trim().split(/\s+/).filter(Boolean), + ), + optionalNumber: jest.fn((value: unknown) => { + if (value === undefined || value === null || value === '') return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : undefined; + }), + pickMainServer: jest.fn(() => 0), + toImageReply: jest.fn((operationKey, query, images) => ({ + imageCount: images.length, + operationKey, + query, + replyText: '[CQ:image,file=base64://Y3V0b2Zm]', + source: 'BangDream 内置插件', + })), + }) as any; + +describe('BangDream cutoff detail operation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('accepts the ycx command alias as a full smoke text with default tier', async () => { + const context = createContext(); + + await expect( + cutoffDetailOperation.execute({ text: 'ycx' }, context), + ).resolves.toMatchObject({ + operationKey: 'bangdream.cutoff.detail', + query: '1000 321', + }); + + expect(drawCutoffDetail).toHaveBeenCalledWith(321, 1000, 0, true); + }); + + it('keeps explicit tier and event id after the ycx alias', async () => { + const context = createContext(); + + await expect( + cutoffDetailOperation.execute({ text: 'ycx 2000 400' }, context), + ).resolves.toMatchObject({ + query: '2000 400', + }); + + expect(drawCutoffDetail).toHaveBeenCalledWith(400, 2000, 0, true); + }); +}); diff --git a/test/qqbot/plugins/bangdream/hook/hook-registry.spec.ts b/test/qqbot/plugins/bangdream/execution/operation-lifecycle.spec.ts similarity index 57% rename from test/qqbot/plugins/bangdream/hook/hook-registry.spec.ts rename to test/qqbot/plugins/bangdream/execution/operation-lifecycle.spec.ts index 9b0fe27..c9cfa95 100644 --- a/test/qqbot/plugins/bangdream/hook/hook-registry.spec.ts +++ b/test/qqbot/plugins/bangdream/execution/operation-lifecycle.spec.ts @@ -1,12 +1,12 @@ import { - createBangDreamHookContext, - BangDreamHookRegistry, -} from '@/modules/qqbot/plugins/bangdream/src/application/hook/hook-registry'; + createBangDreamOperationLifecycleContext, + BangDreamOperationLifecycle, +} from '@/modules/qqbot/plugins/bangdream/src/application/execution/operation-lifecycle'; -describe('BangDream hook registry', () => { - it('emits simple lifecycle hooks by configured order', async () => { +describe('BangDream operation lifecycle', () => { + it('emits simple lifecycle observers by configured order', async () => { const events: string[] = []; - const registry = new BangDreamHookRegistry([ + const lifecycle = new BangDreamOperationLifecycle([ { afterOutput: () => { events.push('afterOutput:2'); @@ -28,12 +28,12 @@ describe('BangDream hook registry', () => { order: 1, }, ]); - const context = createBangDreamHookContext('bangdream.song.search', { + const context = createBangDreamOperationLifecycleContext('bangdream.song.search', { text: '夏祭り', }); - await registry.beforeParse(context); - await registry.afterOutput(context); + await lifecycle.beforeParse(context); + await lifecycle.afterOutput(context); expect(events).toEqual([ 'beforeParse:1', @@ -43,9 +43,9 @@ describe('BangDream hook registry', () => { ]); }); - it('passes shared context and error to error hooks', async () => { + it('passes shared context and error to error observers', async () => { const errors: string[] = []; - const registry = new BangDreamHookRegistry([ + const lifecycle = new BangDreamOperationLifecycle([ { name: 'error-recorder', onError: (context, error) => { @@ -53,12 +53,12 @@ describe('BangDream hook registry', () => { }, }, ]); - const context = createBangDreamHookContext('bangdream.event.search', { + const context = createBangDreamOperationLifecycleContext('bangdream.event.search', { args: ['50'], }); context.stage = 'handler'; - await registry.onError(context, '活动渲染失败'); + await lifecycle.onError(context, '活动渲染失败'); expect(context.query).toBe('50'); expect(errors).toEqual(['bangdream.event.search:handler:活动渲染失败']); diff --git a/test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts b/test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts index c74cc44..7785f34 100644 --- a/test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts +++ b/test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts @@ -5,6 +5,7 @@ import { parseQqbotPluginManifest } from '@/modules/qqbot/plugin-platform/domain type BangDreamSqlCommandRow = { aliases: string[]; cooldownMs: number; + name?: string; operationKey: string; remark?: string; }; @@ -13,7 +14,7 @@ const BANGDREAM_FULL_SQL_ROW_PATTERN = /\(\d+, '[^']+', '[^']+', '(\[[^']*\])', '\[[^']*\]', 'bangdream', '([^']+)', 'plain', 'all', '\{\}', '', '[^']*', 1, 0, (\d+), '([^']*)'\)/g; const BANGDREAM_REFACTOR_V3_SQL_ROW_PATTERN = - /\(\s*\d+,\s*'([^']+)',\s*'[^']+',\s*'[^']+',\s*'[^']+',\s*'(\[[^']*\])',\s*'bangdream',\s*1,\s*(\d+)\s*\)/g; + /\(\s*\d+,\s*'([^']+)',\s*'[^']+',\s*'[^']+',\s*'([^']*)',\s*'(\[[^']*\])',\s*'bangdream',\s*1,\s*(\d+)\s*\)/g; const pluginRoot = join( process.cwd(), @@ -47,8 +48,9 @@ function getBangDreamFullSqlCommandRows(sql: string) { function getBangDreamRefactorV3SqlCommandRows(sql: string) { return Array.from(sql.matchAll(BANGDREAM_REFACTOR_V3_SQL_ROW_PATTERN)).map( (match): BangDreamSqlCommandRow => ({ - aliases: JSON.parse(match[2]) as string[], - cooldownMs: Number(match[3]) * 1000, + aliases: JSON.parse(match[3]) as string[], + cooldownMs: Number(match[4]) * 1000, + name: match[2], operationKey: match[1], }), ); @@ -57,6 +59,7 @@ function getBangDreamRefactorV3SqlCommandRows(sql: string) { function expectSqlRowsMatchManifest( sqlRows: BangDreamSqlCommandRow[], options: { + aliasSource?: 'manifest' | 'plugin-manifest-only'; requireRemark?: boolean; } = {}, ) { @@ -77,11 +80,16 @@ function expectSqlRowsMatchManifest( for (const operation of manifest.operations) { const row = rowsByKey.get(operation.key); - expect(row).toEqual( - expect.objectContaining({ - aliases: [...operation.aliases], - }), - ); + if (options.aliasSource === 'plugin-manifest-only') { + expect(row?.aliases).toEqual([]); + expect(row?.name).toBe(''); + } else { + expect(row).toEqual( + expect.objectContaining({ + aliases: [...operation.aliases], + }), + ); + } expect(row?.cooldownMs).toBeGreaterThan(0); if (options.requireRemark) { expect(row?.remark).not.toHaveLength(0); @@ -111,7 +119,12 @@ describe('refactor v3 BangDream command seed SQL', () => { const sqlRows = getBangDreamRefactorV3SqlCommandRows(sql); it('keeps fresh-schema BangDream seed aligned with the manifest', () => { - expectSqlRowsMatchManifest(sqlRows); + expectSqlRowsMatchManifest(sqlRows, { + aliasSource: 'plugin-manifest-only', + }); + for (const operation of manifest.operations) { + expect(sql).not.toContain(JSON.stringify(operation.aliases)); + } }); it('reactivates dirty command rows on duplicate keys', () => { diff --git a/test/qqbot/plugins/bangdream/policy/policy.spec.ts b/test/qqbot/plugins/bangdream/policy/policy.spec.ts index b182fda..94a9c10 100644 --- a/test/qqbot/plugins/bangdream/policy/policy.spec.ts +++ b/test/qqbot/plugins/bangdream/policy/policy.spec.ts @@ -1,4 +1,4 @@ -jest.mock('@/modules/qqbot/plugins/bangdream/src/application/main-data-store', () => ({ +jest.mock('@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache', () => ({ __esModule: true, default: {}, })); diff --git a/test/qqbot/plugins/bangdream/provider/file-cache.client.spec.ts b/test/qqbot/plugins/bangdream/provider/remote-resource.client.spec.ts similarity index 63% rename from test/qqbot/plugins/bangdream/provider/file-cache.client.spec.ts rename to test/qqbot/plugins/bangdream/provider/remote-resource.client.spec.ts index ae75447..6d26552 100644 --- a/test/qqbot/plugins/bangdream/provider/file-cache.client.spec.ts +++ b/test/qqbot/plugins/bangdream/provider/remote-resource.client.spec.ts @@ -1,4 +1,4 @@ -describe('BangDream file cache client', () => { +describe('BangDream remote resource client', () => { beforeEach(() => { jest.resetModules(); jest.clearAllMocks(); @@ -15,15 +15,15 @@ describe('BangDream file cache client', () => { '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io' ); configureBangDreamRuntimeIo({ requestArrayBuffer }); - const { download } = - await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client'); + const { fetchRemoteResourceBuffer } = + await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client'); - await expect(download('https://example.com/card.png')).rejects.toThrow( - 'timeout of 8000ms exceeded', - ); - await expect(download('https://example.com/card.png')).resolves.toEqual( - imageBuffer, - ); + await expect( + fetchRemoteResourceBuffer('https://example.com/card.png'), + ).rejects.toThrow('timeout of 8000ms exceeded'); + await expect( + fetchRemoteResourceBuffer('https://example.com/card.png'), + ).resolves.toEqual(imageBuffer); expect(requestArrayBuffer).toHaveBeenCalledTimes(2); }); @@ -37,15 +37,15 @@ describe('BangDream file cache client', () => { '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io' ); configureBangDreamRuntimeIo({ requestArrayBuffer }); - const { download } = - await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client'); + const { fetchRemoteResourceBuffer } = + await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client'); - await expect(download('https://example.com/missing.png')).rejects.toThrow( - 'not found', - ); - await expect(download('https://example.com/missing.png')).rejects.toThrow( - 'errorUrlCache includes url', - ); + await expect( + fetchRemoteResourceBuffer('https://example.com/missing.png'), + ).rejects.toThrow('not found'); + await expect( + fetchRemoteResourceBuffer('https://example.com/missing.png'), + ).rejects.toThrow('errorUrlCache includes url'); expect(requestArrayBuffer).toHaveBeenCalledTimes(1); }); @@ -59,15 +59,15 @@ describe('BangDream file cache client', () => { '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io' ); configureBangDreamRuntimeIo({ requestArrayBuffer }); - const { download } = - await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/file-cache.client'); + const { fetchRemoteResourceBuffer } = + await import('@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/remote-resource.client'); - await expect(download('https://example.com/missing.svg')).rejects.toThrow( - 'not found', - ); - await expect(download('https://example.com/missing.svg')).rejects.toThrow( - 'errorUrlCache includes url', - ); + await expect( + fetchRemoteResourceBuffer('https://example.com/missing.svg'), + ).rejects.toThrow('not found'); + await expect( + fetchRemoteResourceBuffer('https://example.com/missing.svg'), + ).rejects.toThrow('errorUrlCache includes url'); expect(requestArrayBuffer).toHaveBeenCalledTimes(1); }); }); diff --git a/test/qqbot/plugins/bangdream/search/fuzzy-search.spec.ts b/test/qqbot/plugins/bangdream/search/fuzzy-search.spec.ts index a0db79a..df2b627 100644 --- a/test/qqbot/plugins/bangdream/search/fuzzy-search.spec.ts +++ b/test/qqbot/plugins/bangdream/search/fuzzy-search.spec.ts @@ -6,9 +6,9 @@ import { match, } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search'; import { - createDefaultFuzzySearchRuleRegistry, + createDefaultFuzzySearchRules, createFuzzySearchKeyword, -} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rule.registry'; +} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search-rules'; import type { FuzzySearchResult } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.types'; import { createBangDreamEntityMatcher } from '@/modules/qqbot/plugins/bangdream/src/domain/search/entity-list-matcher'; import { configureBangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; @@ -52,8 +52,8 @@ describe('BangDream fuzzy search helpers', () => { expect(checkRelationList(136, ['<100', '>200'])).toBe(false); }); - it('routes configured aliases through the rule registry', () => { - const registry = createDefaultFuzzySearchRuleRegistry({ + it('routes configured aliases through the rule set', () => { + const rules = createDefaultFuzzySearchRules({ songs: { '136': ['夏祭り'], }, @@ -63,7 +63,7 @@ describe('BangDream fuzzy search helpers', () => { (result[key] ??= []).push(value); }; - expect(registry.match(createFuzzySearchKeyword('夏祭り'), push)).toBe(true); + expect(rules.match(createFuzzySearchKeyword('夏祭り'), push)).toBe(true); expect(result).toEqual({ songs: [136] }); }); }); diff --git a/test/qqbot/plugins/ff14-market/qqbot-ff14-worlds.spec.ts b/test/qqbot/plugins/ff14-market/ff14-worlds.spec.ts similarity index 81% rename from test/qqbot/plugins/ff14-market/qqbot-ff14-worlds.spec.ts rename to test/qqbot/plugins/ff14-market/ff14-worlds.spec.ts index fde0552..14131db 100644 --- a/test/qqbot/plugins/ff14-market/qqbot-ff14-worlds.spec.ts +++ b/test/qqbot/plugins/ff14-market/ff14-worlds.spec.ts @@ -1,11 +1,11 @@ import { - buildQqbotFf14MarketCatalog, - buildQqbotFf14MarketCatalogFromTree, - resolveQqbotFf14MarketTarget, -} from '@/modules/qqbot/plugins/ff14-market/src'; + buildFf14MarketCatalog, + buildFf14MarketCatalogFromTree, + resolveFf14MarketTarget, +} from '@/modules/qqbot/plugins/ff14-market/src/domain/ff14-worlds'; describe('qqbot ff14 market worlds', () => { - const catalog = buildQqbotFf14MarketCatalog({ + const catalog = buildFf14MarketCatalog({ dataCenters: [ { childrenCode: '中国', label: '猫小胖', value: '猫小胖' }, { childrenCode: '中国', label: '陆行鸟', value: '陆行鸟' }, @@ -18,7 +18,7 @@ describe('qqbot ff14 market worlds', () => { }); it('resolves world labels from dict catalog', () => { - expect(resolveQqbotFf14MarketTarget(catalog, { world: '琥珀原' })).toEqual({ + expect(resolveFf14MarketTarget(catalog, { world: '琥珀原' })).toEqual({ dataCenter: '猫小胖', label: '中国 / 猫小胖 / 琥珀原', region: '中国', @@ -29,7 +29,7 @@ describe('qqbot ff14 market worlds', () => { it('rejects world and data center mismatch from dict catalog', () => { expect(() => - resolveQqbotFf14MarketTarget(catalog, { + resolveFf14MarketTarget(catalog, { dataCenter: '陆行鸟', world: '琥珀原', }), @@ -37,7 +37,7 @@ describe('qqbot ff14 market worlds', () => { }); it('builds catalog from dict tree relations', () => { - const treeCatalog = buildQqbotFf14MarketCatalogFromTree([ + const treeCatalog = buildFf14MarketCatalogFromTree([ { children: [ { @@ -74,7 +74,7 @@ describe('qqbot ff14 market worlds', () => { ]); expect( - resolveQqbotFf14MarketTarget(treeCatalog, { world: '琥珀原' }), + resolveFf14MarketTarget(treeCatalog, { world: '琥珀原' }), ).toEqual({ dataCenter: '猫小胖', label: '中国 / 猫小胖 / 琥珀原', diff --git a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/test/qqbot/plugins/fflogs/fflogs-client.spec.ts similarity index 98% rename from test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts rename to test/qqbot/plugins/fflogs/fflogs-client.spec.ts index 9743a19..9478f95 100644 --- a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts +++ b/test/qqbot/plugins/fflogs/fflogs-client.spec.ts @@ -1,6 +1,6 @@ import { FflogsClient } from '@/modules/qqbot/plugins/fflogs/src/infrastructure/integration/fflogs-client'; -describe('QqbotFflogsClientService', () => { +describe('FflogsClientService', () => { const dicts = { FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }], FFLOGS_METRIC_LABEL: [ diff --git a/test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts b/test/qqbot/plugins/repeater/repeater-plugin.spec.ts similarity index 98% rename from test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts rename to test/qqbot/plugins/repeater/repeater-plugin.spec.ts index 78ded9a..6f9d4c4 100644 --- a/test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts +++ b/test/qqbot/plugins/repeater/repeater-plugin.spec.ts @@ -40,7 +40,7 @@ function createService(config: Record) { return { sendService, service }; } -describe('QqbotRepeaterPluginService risk-control defaults', () => { +describe('RepeaterPluginService risk-control defaults', () => { beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-12T00:00:00.000Z')); diff --git a/test/refactor-v3/schema-map.spec.ts b/test/refactor-v3/schema-map.spec.ts index cf57ddd..3a5b7f1 100644 --- a/test/refactor-v3/schema-map.spec.ts +++ b/test/refactor-v3/schema-map.spec.ts @@ -92,7 +92,7 @@ describe('refactor v3 schema skeleton', () => { expect(blogTermColumns).toContain('parent_id'); }); - it('seeds qqbot command rows with required command code and aliases', () => { + it('seeds qqbot command rows with required command code and manifest-owned aliases', () => { const seed = readFileSync( join(root, 'sql/refactor-v3/01-seed-core.sql'), 'utf8', @@ -103,7 +103,57 @@ describe('refactor v3 schema skeleton', () => { expect(commandColumns).toContain('code'); expect(commandColumns).toContain('aliases'); - expect(seed).toContain('["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]'); + expect(seed).toContain("'bangdream.song.search'"); + expect(seed).toContain("'bd'"); + expect(seed).toContain("'[]'"); + expect(seed).not.toContain('["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]'); + }); + + it('seeds qqbot admin menus and button permissions required by current pages', () => { + const seed = readFileSync( + join(root, 'sql/refactor-v3/01-seed-core.sql'), + 'utf8', + ); + const requiredNames = [ + 'QqBotDashboard', + 'QqBotAccount', + 'QqBotAccountConfig', + 'QqBotAccountConfigButton', + 'QqBotAccountCreate', + 'QqBotAccountDelete', + 'QqBotAccountEdit', + 'QqBotAccountKick', + 'QqBotAccountRefreshLogin', + 'QqBotRule', + 'QqBotRuleCreate', + 'QqBotRuleDelete', + 'QqBotRuleEdit', + 'QqBotRuleToggle', + 'QqBotCommand', + 'QqBotCommandCreate', + 'QqBotCommandDelete', + 'QqBotCommandEdit', + 'QqBotCommandTest', + 'QqBotCommandToggle', + 'QqBotPlugin', + 'QqBotConversation', + 'QqBotMessage', + 'QqBotSendLog', + 'QqBotSendPrivate', + 'QqBotSendGroup', + 'QqBotPermission', + 'QqBotPermissionCreate', + 'QqBotPermissionDelete', + 'QqBotPermissionEdit', + ]; + + for (const name of requiredNames) { + expect(seed).toContain(`'${name}'`); + } + + expect(seed).toContain("'QqBot:Account:RefreshLogin'"); + expect(seed).toContain("'QqBot:Command:Test'"); + expect(seed).toContain('INSERT IGNORE INTO admin_role_menu'); }); it('runs dry-run SQL sources with utf8mb4 and fails on native mysql errors', () => {