refactor: 收敛QQBot插件与NapCat架构

This commit is contained in:
sunlei 2026-06-16 06:37:13 +08:00
parent 1f73673ebe
commit 3f664ca4c9
141 changed files with 3352 additions and 1639 deletions

View File

@ -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. |

View File

@ -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,

View File

@ -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 (

View File

@ -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

View File

@ -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()

View File

@ -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: [

View File

@ -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<QqbotAccount>,
@InjectRepository(QqbotAccountAbility)
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
@InjectRepository(QqbotAccountNapcat)
private readonly accountNapcatRepository: Repository<QqbotAccountNapcat>,
@InjectRepository(QqbotNapcatContainer)
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
@InjectRepository(NapcatAccountBinding)
private readonly accountNapcatRepository: Repository<NapcatAccountBinding>,
@InjectRepository(NapcatContainer)
private readonly napcatContainerRepository: Repository<NapcatContainer>,
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<string, QqbotAccountNapcat>();
const bindingMap = new Map<string, NapcatAccountBinding>();
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<string, QqbotNapcatContainer>();
const containerMap = new Map<string, NapcatContainer>();
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<QqbotNapcatRuntimeStatusSnapshot | undefined> {
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;

View File

@ -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 {

View File

@ -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 账号' })

View File

@ -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;
}

View File

@ -152,12 +152,14 @@ export type QqbotPluginOperationContext = {
};
export type QqbotPluginOperation<Input = any, Output = any> = {
aliases?: string[];
cacheTtlMs?: number;
description?: string;
inputSchema?: Record<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
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<string, any>;
@ -213,6 +216,7 @@ export type QqbotPluginOperationSummary = {
name: string;
outputSchema?: Record<string, any>;
pluginKey: string;
timeoutMs?: number;
triggerMode: QqbotPluginTriggerMode;
};

View File

@ -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),
],

View File

@ -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<string, any> = {},
) {
const credential = await this.getCredential(container);
return this.requestNapcat<T>(container, path, body, credential);
return this.webuiClient
.post<T>(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<NapcatCredential>(
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<T>(
container: QqbotNapcatRuntime,
path: string,
body: Record<string, any> = {},
credential?: string,
): Promise<T> {
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<T>((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<T>;
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,

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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<QqbotNapcatContainer>,
@InjectRepository(QqbotAccountNapcat)
private readonly bindingRepository: Repository<QqbotAccountNapcat>,
@InjectRepository(NapcatContainer)
private readonly containerRepository: Repository<NapcatContainer>,
@InjectRepository(NapcatAccountBinding)
private readonly bindingRepository: Repository<NapcatAccountBinding>,
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<NapcatDockerDeviceOptions | undefined> {
if (
@ -199,7 +199,7 @@ export class QqbotNapcatContainerService {
}
async tryAutoLogin(
container: QqbotNapcatContainer,
container: NapcatContainer,
options: NapcatLoginEnvOptions,
): Promise<NapcatAutoLoginResult> {
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<QqbotNapcatRuntimeStatusSnapshot> {
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,

View File

@ -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<string, unknown>): Promise<unknown>;
};
export type NapcatWebuiRuntime = {
baseUrl: string;
id?: string;
webuiToken?: null | string;
};
type NapcatApiResponse<T> = {
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<T>(
container: NapcatWebuiRuntime,
path: string,
body: Record<string, unknown> = {},
) {
const credential = await this.getCredential(container);
return this.request<T>(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<NapcatCredential>(
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<T>(
container: NapcatWebuiRuntime,
path: string,
body: Record<string, unknown> = {},
credential?: string,
): Promise<T> {
const target = new URL(path, container.baseUrl);
const payload = JSON.stringify(body);
const client = target.protocol === 'https:' ? https : http;
return new Promise<T>((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<T>;
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) {}

View File

@ -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,
];

View File

@ -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;

View File

@ -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;

View File

@ -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<string, unknown>)
: {};
}
private async saveChallenge(input: {
challengePayload: null | Record<string, unknown>;
challengeType: NapcatLoginChallengeType;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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;
```

View File

@ -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;
}
}

View File

@ -0,0 +1,42 @@
import type {
QqbotPlugin,
QqbotPluginInstallation,
} from '@/modules/qqbot/plugin-platform/infrastructure/persistence';
type PluginStateRow = Pick<QqbotPlugin, 'id' | 'pluginKey'>;
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);
}

View File

@ -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<string, QqbotEventPluginPackage>();
private readonly inactivePluginKeys = new Set<string>();
constructor(
private readonly accountService: QqbotAccountService,
private readonly builtinPluginLoader: QqbotBuiltinPluginPackageLoaderService,
@Optional()
@InjectRepository(QqbotPlugin)
private readonly pluginRepository?: Repository<QqbotPlugin>,
@Optional()
@InjectRepository(QqbotPluginInstallation)
private readonly installationRepository?: Repository<QqbotPluginInstallation>,
) {}
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<typeof account> => !!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);
}
}
}

View File

@ -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<string>();
private readonly pluginAliases = new Map<string, string>();
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
constructor(
@Optional()
private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService,
@Optional()
@InjectRepository(QqbotPlugin)
private readonly pluginRepository?: Repository<QqbotPlugin>,
@Optional()
@InjectRepository(QqbotPluginInstallation)
private readonly installationRepository?: Repository<QqbotPluginInstallation>,
) {}
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<string, any>,
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<never>((_, 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);
}
}
}

View File

@ -80,7 +80,7 @@ export class QqbotPluginPlatformController {
packagePath?: string;
},
) {
return vbenSuccess(this.service.validateManifest(body));
return vbenSuccess(this.service.uploadPackage(body));
}
@Post('install')

View File

@ -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<QqbotFf14MarketCatalog> {
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<string, any>) {
return Object.entries(input).reduce<Record<string, any>>(
(result, [key, value]) => {
if (value !== undefined && value !== '') result[key] = value;
return result;
},
{},
);
}
}

View File

@ -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> | boolean;
getDefinition(): QqbotEventPluginDefinition;
getSummary(input: {
accountName?: string;
connectStatus?: string;
selfId: string;
}): Promise<unknown> | unknown;
handleMessage(message: QqbotNormalizedMessage): Promise<boolean> | boolean;
unbind(selfId: string): Promise<boolean> | 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(),

View File

@ -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<string, unknown>;
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<string>('QQBOT_PLUGIN_PACKAGE_ROOT'),
this.configService?.get<string>('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<string>('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<PackedPluginFile>;
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;
}
}

View File

@ -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> | unknown;
dispose?: () => Promise<unknown> | 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<unknown> {
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<void> {
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<string, unknown>,
{},
);
}
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;
}

View File

@ -1,2 +1,3 @@
export * from './worker-runtime';
export * from './worker-runtime.types';
export * from './builtin-plugin-worker-runtime.factory';

View File

@ -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,

View File

@ -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<Buffer | string>,
): 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];
}

View File

@ -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<string, any> = {};
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<never> {
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<Record<string, unknown>>(
'cards-cn-fix.json',
);
for (const key in cardsCnFix) {
bangdreamCatalogCache['cards'][key] = cardsCnFix[key];
}
const skillsCnFix =
await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>(
'skills-cn-fix.json',
);
for (const key in skillsCnFix) {
bangdreamCatalogCache['skills'][key] = skillsCnFix[key];
}
const areaItemFix =
await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>(
'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<void> | 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<void> {
if (isCatalogReady()) {
return;
}
await Promise.race([
ensureCatalogInitialLoad(),
rejectAfter(getCatalogReadyTimeoutMs()),
]);
if (!isCatalogReady()) {
throw new Error('BangDream 主数据未完成关键集合加载');
}
}
export default bangdreamCatalogCache;

View File

@ -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<T = unknown> = Record<string, T>;
export class BangDreamCatalogRepository {
constructor(
private readonly catalog: Record<string, unknown> =
bangdreamCatalogCache as Record<string, unknown>,
) {}
/**
* Bestdori
*
* @param key -
*/
getCollection<T = unknown>(
key: BangDreamCatalogKey,
): BangDreamCatalogCollection<T> {
return (this.catalog[key] ?? {}) as BangDreamCatalogCollection<T>;
}
/**
* rates
*
* @param key -
*/
getValue<T = unknown>(key: BangDreamCatalogKey): T {
return (this.catalog[key] ?? {}) as T;
}
/**
* ID Bestdori
*
* @param key -
* @param id - ID
*/
getEntity<T = unknown>(
key: BangDreamCatalogKey,
id: number | string,
): T | undefined {
return this.getCollection<T>(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();

View File

@ -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> | void;
afterResolve?: (
context: BangDreamOperationLifecycleContext,
) => Promise<void> | void;
beforeParse?: (
context: BangDreamOperationLifecycleContext,
) => Promise<void> | void;
beforeRender?: (
context: BangDreamOperationLifecycleContext,
) => Promise<void> | void;
name: string;
onError?: (
context: BangDreamOperationLifecycleContext,
error: unknown,
) => Promise<void> | 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}`;
}

View File

@ -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> | void;
afterResolve?: (context: BangDreamHookContext) => Promise<void> | void;
beforeParse?: (context: BangDreamHookContext) => Promise<void> | void;
beforeRender?: (context: BangDreamHookContext) => Promise<void> | void;
name: string;
onError?: (
context: BangDreamHookContext,
error: unknown,
) => Promise<void> | 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}`;
}

View File

@ -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<string, any> = {}; //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<never> {
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<Record<string, unknown>>(
'cards-cn-fix.json',
);
for (const key in cardsCnFix) {
mainAPI['cards'][key] = cardsCnFix[key];
}
const skillsCnFix =
await bangDreamStaticPatchProvider.readJson<Record<string, unknown>>(
'skills-cn-fix.json',
);
for (const key in skillsCnFix) {
mainAPI['skills'][key] = skillsCnFix[key];
}
const areaItemFix =
await bangDreamStaticPatchProvider.readJson<Record<string, unknown>>(
'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<void> | 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<void> {
if (isMainDataReady()) {
return;
}
await Promise.race([
ensureMainDataInitialLoad(),
rejectAfter(getMainDataReadyTimeoutMs()),
]);
if (!isMainDataReady()) {
throw new Error('BangDream 主数据未完成关键集合加载');
}
}
export default mainAPI;

View File

@ -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<T = unknown> = Record<string, T>;
export class BangDreamMainDataRepository {
constructor(
private readonly store: Record<string, unknown> = mainAPI as Record<
string,
unknown
>,
) {}
/**
* Bestdori
*
* @param key -
*/
getCollection<T = unknown>(
key: BangDreamMainDataKey,
): BangDreamMainDataCollection<T> {
return (this.store[key] ?? {}) as BangDreamMainDataCollection<T>;
}
/**
* rates
*
* @param key -
*/
getValue<T = unknown>(key: BangDreamMainDataKey): T {
return (this.store[key] ?? {}) as T;
}
/**
* ID Bestdori
*
* @param key -
* @param id - ID
*/
getEntity<T = unknown>(
key: BangDreamMainDataKey,
id: number | string,
): T | undefined {
return this.getCollection<T>(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();

View File

@ -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,
) {}
/**

View File

@ -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,
) {}
/**

View File

@ -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<Record<string, any>>(
const cardData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'cards',
cardId,
);

View File

@ -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');
}
/**

View File

@ -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;

View File

@ -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,
) {}
/**

View File

@ -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,
) {}
/**

View File

@ -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) {

View File

@ -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,
) {}
/**

View File

@ -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;

View File

@ -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,
) {}
/**

View File

@ -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;

View File

@ -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,
) {}
/**

View File

@ -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;
}

View File

@ -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,
) {}
/**

View File

@ -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'];

View File

@ -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,
) {}
/**

View File

@ -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<Array<Buffer | string>> {
//计算模糊搜索结果
const tempCharacterList: Array<Character> = []; //最终输出的角色列表
const characterIdList: Array<number> = Object.keys(mainAPI['characters']).map(
const characterIdList: Array<number> = Object.keys(bangdreamCatalogCache['characters']).map(
Number,
); //所有卡牌ID列表
for (let i = 0; i < characterIdList.length; i++) {

View File

@ -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;

View File

@ -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;

View File

@ -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<Array<Buffer | string>> {
const eventData = bangDreamMainDataRepository.getEntity<Record<string, any>>(
const eventData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'events',
eventId,
);

View File

@ -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,
) {}
/**

View File

@ -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) => {

View File

@ -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<Record<string, any>>('deco');
bangdreamCatalogRepository.getCollection<Record<string, any>>('deco');
const decoAssetName = allDeco[rewardId]?.assetBundleName;
if (!decoAssetName) return undefined;

View File

@ -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<T extends EventStageDataType> =
export class EventStageDataRepository {
constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider,
private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider,
) {}
/**

View File

@ -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<string, any>
>('events', eventId);
if (eventData == undefined) {
@ -337,7 +337,7 @@ export function getPresentEvent(server: Server, time?: number) {
time = Date.now();
}
const eventList: Array<number> = [];
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) => ({

View File

@ -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');
}
/**

View File

@ -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,
) {}
/**

View File

@ -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<string, any>
>('gacha', gachaId);
if (gachaData == undefined) {
@ -247,7 +247,7 @@ export async function getPresentGachaList(
end: number = Date.now(),
): Promise<Array<Gacha>> {
const gachaList: Array<Gacha> = [];
const gachaListMain = bangDreamMainDataRepository.getCollection('gacha');
const gachaListMain = bangdreamCatalogRepository.getCollection('gacha');
for (const gachaId in gachaListMain) {
if (Object.prototype.hasOwnProperty.call(gachaListMain, gachaId)) {

View File

@ -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');
}
/**

View File

@ -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',

View File

@ -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<Canvas> {
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];

View File

@ -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 {

View File

@ -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,
) {}
/**

View File

@ -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,
) {}
/**

View File

@ -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<Record<string, any>>(
const eventData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'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;

View File

@ -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(),

View File

@ -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<typeof createDefaultFuzzySearchRuleRegistry>
let cachedRules:
| ReturnType<typeof createDefaultFuzzySearchRules>
| 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;

View File

@ -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,
) {}
/**

View File

@ -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<Record<string, any>>(
const songData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'songs',
songId,
);
@ -131,7 +131,7 @@ export class Song {
}
//meta数据
const metaData = bangDreamMainDataRepository.getEntity<Record<string, any>>(
const metaData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'meta',
songId,
);
@ -321,7 +321,7 @@ export function getPresentSongList(
end: number = Date.now(),
): Song[] {
const songList: Array<Song> = [];
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];

View File

@ -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');
}
/**

View File

@ -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<string, any>;
key: QqbotBangDreamOperationKey;
key: BangDreamOperationKey;
name?: string;
outputSchema?: Record<string, any>;
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<QqbotBangDreamOperationKey, BangDreamResolvedOperation>;
}): Promise<QqbotBangDreamCommandOutput> {
const operationContext = createBangDreamHookContext(
operationKey: BangDreamOperationKey;
operationsByKey: Map<BangDreamOperationKey, BangDreamResolvedOperation>;
}): Promise<BangDreamCommandOutput> {
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: {

View File

@ -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(

View File

@ -104,4 +104,4 @@ export function createBestdoriProvider(
);
}
export const bangDreamBestdoriProvider = createBestdoriProvider();
export const bangdreamBestdoriProvider = createBestdoriProvider();

View File

@ -75,4 +75,4 @@ export function createHhwxTrackerProvider(
);
}
export const bangDreamHhwxTrackerProvider = createHhwxTrackerProvider();
export const bangdreamHhwxTrackerProvider = createHhwxTrackerProvider();

View File

@ -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;

View File

@ -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('<!DOCTYPE html>');
const slice = Buffer.from(data.subarray(0, htmlSig.length));
if (slice.equals(htmlSig)) {

View File

@ -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<T = object>(
export async function fetchRemoteResourceJson<T = object>(
url: string,
_directory?: string,
_fileName?: string,

View File

@ -29,4 +29,4 @@ export class BangDreamStaticPatchProvider {
}
}
export const bangDreamStaticPatchProvider = new BangDreamStaticPatchProvider();
export const bangdreamStaticPatchProvider = new BangDreamStaticPatchProvider();

View File

@ -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 =

View File

@ -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<QqbotBangDreamCommandOutput>;
) => Promise<BangDreamCommandOutput>;
export type BangDreamOperationModule = {
execute: BangDreamOperationExecute;
expectedImageCount?: number;
handlerName: QqbotBangDreamOperationHandlerName;
handlerName: BangDreamOperationHandlerName;
};

View File

@ -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

View File

@ -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<string, any>) {
return this.client.resolveItem(input);
}

View File

@ -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<string, string | true>();
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,

View File

@ -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;

View File

@ -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();
}

Some files were not shown because too many files have changed in this diff Show More