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. | | 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. | | 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. | | 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. | | 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 | Device identity and login challenge 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. | | 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. | | 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`) KEY `idx_qqbot_account_ability_self` (`self_id`, `ability_type`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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, `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, `base_url` varchar(255) NOT NULL,
`webui_port` int DEFAULT NULL, `webui_port` int DEFAULT NULL,
`webui_token` varchar(255) 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), `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), `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_qqbot_napcat_container_name` (`name`), UNIQUE KEY `uk_napcat_container_name` (`container_name`),
KEY `idx_qqbot_napcat_container_status` (`status`, `is_deleted`) KEY `idx_napcat_container_status` (`status`, `is_deleted`),
KEY `idx_napcat_container_account` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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, `id` bigint NOT NULL,
`account_id` bigint NOT NULL, `account_id` bigint NOT NULL,
`container_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, `is_primary` tinyint(1) NOT NULL DEFAULT 1,
`last_login_at` datetime DEFAULT NULL, `last_login_at` datetime DEFAULT NULL,
`remark` varchar(255) NOT NULL DEFAULT '', `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), `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), `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_qqbot_account_napcat_account` (`account_id`, `is_deleted`), UNIQUE KEY `uk_napcat_account_binding_account` (`account_id`),
KEY `idx_qqbot_account_napcat_container` (`container_id`, `is_deleted`) 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `qqbot_config` ( CREATE TABLE IF NOT EXISTS `qqbot_config` (
@ -366,141 +429,6 @@ PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt; EXECUTE qqbot_stmt;
DEALLOCATE PREPARE 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 = ( SET @qqbot_sql = (
SELECT IF( SELECT IF(
COUNT(*) > 0, COUNT(*) > 0,

View File

@ -877,33 +877,8 @@ CREATE TABLE IF NOT EXISTS qqbot_plugin_runtime_event (
CREATE TABLE IF NOT EXISTS napcat_container ( CREATE TABLE IF NOT EXISTS napcat_container (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL, account_id BIGINT NULL,
container_name VARCHAR(128) NOT NULL, container_name VARCHAR(120) 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,
base_url VARCHAR(255) NOT NULL, base_url VARCHAR(255) NOT NULL,
webui_port INT NULL, webui_port INT NULL,
webui_token VARCHAR(255) 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, is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_napcat_container_name (name), UNIQUE KEY uk_napcat_container_name (container_name),
KEY idx_qqbot_napcat_container_status (status, is_deleted) KEY idx_napcat_container_status (status, is_deleted),
KEY idx_napcat_container_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_device_identity ( 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, id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
container_id BIGINT NOT NULL, container_id BIGINT NOT NULL,
device_identity_id BIGINT NOT NULL, device_identity_id BIGINT NULL,
status VARCHAR(32) NOT 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, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_login_session ( CREATE TABLE IF NOT EXISTS napcat_login_session (

View File

@ -117,45 +117,36 @@ INSERT INTO admin_menu (
1, 1,
110 110
), ),
( (2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0),
2041700000000100402, (2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1),
2041700000000100400, (2041700000000100410, 2041700000000100400, 'QqBotAccountConfig', '/qqbot/account/config', '/qqbot/account/config', NULL, 'QqBot:Account:Config', 'menu', '{"activePath":"/qqbot/account","hideInMenu":true,"title":"账号功能配置"}', 1, 0),
'QqBotAccount', (2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 1, 0),
'/qqbot/account', (2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0),
'/qqbot/account/list', (2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0),
NULL, (2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0),
'QqBot:Account:List', (2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0),
'menu', (2041700000000120406, 2041700000000100402, 'QqBotAccountConfigButton', NULL, NULL, NULL, 'QqBot:Account:Config', 'button', '{"title":"配置"}', 1, 0),
'{"icon":"lucide:radio-receiver","title":"账号连接"}', (2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2),
1, (2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0),
1 (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, (2041700000000100408, 2041700000000100400, 'QqBotCommand', '/qqbot/command', '/qqbot/command/list', NULL, 'QqBot:Command:List', 'menu', '{"icon":"lucide:square-terminal","title":"在线命令"}', 1, 3),
2041700000000100400, (2041700000000120441, 2041700000000100408, 'QqBotCommandCreate', NULL, NULL, NULL, 'QqBot:Command:Create', 'button', '{"title":"common.create"}', 1, 0),
'QqBotCommand', (2041700000000120442, 2041700000000100408, 'QqBotCommandEdit', NULL, NULL, NULL, 'QqBot:Command:Edit', 'button', '{"title":"common.edit"}', 1, 0),
'/qqbot/command', (2041700000000120443, 2041700000000100408, 'QqBotCommandDelete', NULL, NULL, NULL, 'QqBot:Command:Delete', 'button', '{"title":"common.delete"}', 1, 0),
'/qqbot/command/list', (2041700000000120444, 2041700000000100408, 'QqBotCommandToggle', NULL, NULL, NULL, 'QqBot:Command:Toggle', 'button', '{"title":"启停"}', 1, 0),
NULL, (2041700000000120445, 2041700000000100408, 'QqBotCommandTest', NULL, NULL, NULL, 'QqBot:Command:Test', 'button', '{"title":"测试命令"}', 1, 0),
'QqBot:Command:List', (2041700000000100409, 2041700000000100400, 'QqBotPlugin', '/qqbot/plugin', '/qqbot/plugin/list', NULL, 'QqBot:Plugin:List', 'menu', '{"icon":"lucide:plug","title":"插件能力"}', 1, 4),
'menu', (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 5),
'{"icon":"lucide:square-terminal","title":"在线命令"}', (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 6),
1, (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 7),
3 (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),
2041700000000100409, (2041700000000120431, 2041700000000100407, 'QqBotPermissionCreate', NULL, NULL, NULL, 'QqBot:Permission:Create', 'button', '{"title":"common.create"}', 1, 0),
2041700000000100400, (2041700000000120432, 2041700000000100407, 'QqBotPermissionEdit', NULL, NULL, NULL, 'QqBot:Permission:Edit', 'button', '{"title":"common.edit"}', 1, 0),
'QqBotPlugin', (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0)
'/qqbot/plugin',
'/qqbot/plugin/list',
NULL,
'QqBot:Plugin:List',
'menu',
'{"icon":"lucide:plug","title":"插件能力"}',
1,
4
)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
pid = VALUES(pid), pid = VALUES(pid),
path = VALUES(path), path = VALUES(path),
@ -280,8 +271,8 @@ INSERT INTO qqbot_command (
'bangdream.song.search', 'bangdream.song.search',
'bangdream_song', 'bangdream_song',
'bd', 'bd',
'BangDream 查歌', '',
'["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -291,8 +282,8 @@ INSERT INTO qqbot_command (
'bangdream.song.chart', 'bangdream.song.chart',
'bangdream_song_chart', 'bangdream_song_chart',
'bangdream_song_chart', 'bangdream_song_chart',
'BangDream 查谱面', '',
'["查谱面","谱面","bd谱面"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -302,8 +293,8 @@ INSERT INTO qqbot_command (
'bangdream.song.random', 'bangdream.song.random',
'bangdream_song_random', 'bangdream_song_random',
'bangdream_song_random', 'bangdream_song_random',
'BangDream 随机曲', '',
'["随机曲","随机","bd随机"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -313,8 +304,8 @@ INSERT INTO qqbot_command (
'bangdream.song.meta', 'bangdream.song.meta',
'bangdream_song_meta', 'bangdream_song_meta',
'bangdream_song_meta', 'bangdream_song_meta',
'BangDream 分数表', '',
'["查询分数表","查分数表","查询分数榜","查分数榜","bd分数表"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -324,8 +315,8 @@ INSERT INTO qqbot_command (
'bangdream.card.search', 'bangdream.card.search',
'bangdream_card', 'bangdream_card',
'bangdream_card', 'bangdream_card',
'BangDream 查卡', '',
'["查卡","查卡牌","bd查卡"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -335,8 +326,8 @@ INSERT INTO qqbot_command (
'bangdream.card.illustration', 'bangdream.card.illustration',
'bangdream_card_illustration', 'bangdream_card_illustration',
'bangdream_card_illustration', 'bangdream_card_illustration',
'BangDream 查卡面', '',
'["查卡面","查卡插画","查插画","bd卡面"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -346,8 +337,8 @@ INSERT INTO qqbot_command (
'bangdream.character.search', 'bangdream.character.search',
'bangdream_character', 'bangdream_character',
'bangdream_character', 'bangdream_character',
'BangDream 查角色', '',
'["查角色","bd角色"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -357,8 +348,8 @@ INSERT INTO qqbot_command (
'bangdream.event.search', 'bangdream.event.search',
'bangdream_event', 'bangdream_event',
'bangdream_event', 'bangdream_event',
'BangDream 查活动', '',
'["查活动","bd活动"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -368,8 +359,8 @@ INSERT INTO qqbot_command (
'bangdream.event.stage', 'bangdream.event.stage',
'bangdream_event_stage', 'bangdream_event_stage',
'bangdream_event_stage', 'bangdream_event_stage',
'BangDream 查试炼', '',
'["查试炼","查stage","查舞台","查festival","查5v5"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -379,8 +370,8 @@ INSERT INTO qqbot_command (
'bangdream.player.search', 'bangdream.player.search',
'bangdream_player', 'bangdream_player',
'bangdream_player', 'bangdream_player',
'BangDream 查玩家', '',
'["查玩家","查询玩家","bd玩家"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -390,8 +381,8 @@ INSERT INTO qqbot_command (
'bangdream.gacha.search', 'bangdream.gacha.search',
'bangdream_gacha', 'bangdream_gacha',
'bangdream_gacha', 'bangdream_gacha',
'BangDream 查卡池', '',
'["查卡池","bd卡池"]', '[]',
'bangdream', 'bangdream',
1, 1,
2 2
@ -401,8 +392,8 @@ INSERT INTO qqbot_command (
'bangdream.gacha.simulate', 'bangdream.gacha.simulate',
'bangdream_gacha_simulate', 'bangdream_gacha_simulate',
'bangdream_gacha_simulate', 'bangdream_gacha_simulate',
'BangDream 抽卡模拟', '',
'["抽卡模拟","bd抽卡"]', '[]',
'bangdream', 'bangdream',
1, 1,
3 3
@ -412,8 +403,8 @@ INSERT INTO qqbot_command (
'bangdream.cutoff.detail', 'bangdream.cutoff.detail',
'bangdream_cutoff_detail', 'bangdream_cutoff_detail',
'bangdream_cutoff_detail', 'bangdream_cutoff_detail',
'BangDream ycx', '',
'["ycx","预测线","查档线","bd档线"]', '[]',
'bangdream', 'bangdream',
1, 1,
3 3
@ -423,8 +414,8 @@ INSERT INTO qqbot_command (
'bangdream.cutoff.all', 'bangdream.cutoff.all',
'bangdream_cutoff_all', 'bangdream_cutoff_all',
'bangdream_cutoff_all', 'bangdream_cutoff_all',
'BangDream ycxall', '',
'["ycxall","myycx","全部档线"]', '[]',
'bangdream', 'bangdream',
1, 1,
3 3
@ -434,8 +425,8 @@ INSERT INTO qqbot_command (
'bangdream.cutoff.recent', 'bangdream.cutoff.recent',
'bangdream_cutoff_recent', 'bangdream_cutoff_recent',
'bangdream_cutoff_recent', 'bangdream_cutoff_recent',
'BangDream lsycx', '',
'["lsycx","历史档线","近期档线"]', '[]',
'bangdream', 'bangdream',
1, 1,
3 3

View File

@ -79,6 +79,18 @@ WHERE table_schema = DATABASE()
AND table_name = 'napcat_account_binding' AND table_name = 'napcat_account_binding'
AND index_name = 'uk_napcat_account_binding_account'; 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 SELECT 'index_napcat_login_session_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() 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 { AssetModule } from './modules/asset/asset.module';
import { BlogContentModule } from './modules/blog/blog-content.module'; import { BlogContentModule } from './modules/blog/blog-content.module';
import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.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 { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module';
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module'; import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
import { RuntimeModule } from './runtime'; import { RuntimeModule } from './runtime';
@ -74,6 +75,7 @@ import { RuntimeModule } from './runtime';
WordpressMirrorModule, WordpressMirrorModule,
AssetModule, AssetModule,
QqbotCoreModule, QqbotCoreModule,
QqbotNapcatModule,
QqbotPluginPlatformModule, QqbotPluginPlatformModule,
], ],
providers: [ providers: [

View File

@ -16,8 +16,8 @@ import type {
QqbotAccountQueryDto, QqbotAccountQueryDto,
QqbotAccountUpdateDto, QqbotAccountUpdateDto,
} from '../../contract/account/qqbot-account.dto'; } from '../../contract/account/qqbot-account.dto';
import { QqbotAccountNapcat } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-account-napcat.entity'; import { NapcatAccountBinding } from '@/modules/qqbot/napcat/infrastructure/persistence/napcat-account-binding.entity';
import { QqbotNapcatContainer } from '@/modules/qqbot/napcat/infrastructure/persistence/qqbot-napcat-container.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 { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service';
import { import {
QQBOT_DEFAULT_PAGE_NO, QQBOT_DEFAULT_PAGE_NO,
@ -45,10 +45,10 @@ export class QqbotAccountService {
private readonly accountRepository: Repository<QqbotAccount>, private readonly accountRepository: Repository<QqbotAccount>,
@InjectRepository(QqbotAccountAbility) @InjectRepository(QqbotAccountAbility)
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>, private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
@InjectRepository(QqbotAccountNapcat) @InjectRepository(NapcatAccountBinding)
private readonly accountNapcatRepository: Repository<QqbotAccountNapcat>, private readonly accountNapcatRepository: Repository<NapcatAccountBinding>,
@InjectRepository(QqbotNapcatContainer) @InjectRepository(NapcatContainer)
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>, private readonly napcatContainerRepository: Repository<NapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService, private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
@Optional() @Optional()
@ -437,7 +437,7 @@ export class QqbotAccountService {
.orderBy('binding.isPrimary', 'DESC') .orderBy('binding.isPrimary', 'DESC')
.addOrderBy('binding.updateTime', 'DESC') .addOrderBy('binding.updateTime', 'DESC')
.getMany(); .getMany();
const bindingMap = new Map<string, QqbotAccountNapcat>(); const bindingMap = new Map<string, NapcatAccountBinding>();
for (const binding of bindings) { for (const binding of bindings) {
if (!bindingMap.has(binding.accountId)) { if (!bindingMap.has(binding.accountId)) {
bindingMap.set(binding.accountId, binding); bindingMap.set(binding.accountId, binding);
@ -447,7 +447,7 @@ export class QqbotAccountService {
const containerIds = Array.from( const containerIds = Array.from(
new Set(bindings.map((binding) => binding.containerId).filter(Boolean)), 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) { if (containerIds.length > 0) {
const containerBuilder = this.napcatContainerRepository const containerBuilder = this.napcatContainerRepository
.createQueryBuilder('container'); .createQueryBuilder('container');
@ -500,7 +500,7 @@ export class QqbotAccountService {
private async syncNapcatRuntimeState( private async syncNapcatRuntimeState(
account: QqbotAccount, account: QqbotAccount,
container?: QqbotNapcatContainer, container?: NapcatContainer,
options: { autoLogin?: boolean } = {}, options: { autoLogin?: boolean } = {},
) { ) {
const runtimeStatus = await this.getNapcatRuntimeStatus(account, container); const runtimeStatus = await this.getNapcatRuntimeStatus(account, container);
@ -561,7 +561,7 @@ export class QqbotAccountService {
private async getNapcatRuntimeStatus( private async getNapcatRuntimeStatus(
account: QqbotAccount, account: QqbotAccount,
container?: QqbotNapcatContainer, container?: NapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot | undefined> { ): Promise<QqbotNapcatRuntimeStatusSnapshot | undefined> {
if (!container) return undefined; if (!container) return undefined;
const cached = this.toCachedNapcatRuntimeStatus(container); const cached = this.toCachedNapcatRuntimeStatus(container);
@ -600,7 +600,7 @@ export class QqbotAccountService {
} }
private toCachedNapcatRuntimeStatus( private toCachedNapcatRuntimeStatus(
container: QqbotNapcatContainer, container: NapcatContainer,
): QqbotNapcatRuntimeStatusSnapshot { ): QqbotNapcatRuntimeStatusSnapshot {
const containerOnline = container.status === 'running'; const containerOnline = container.status === 'running';
const lastError = this.toolsService.toTrimmedString(container.lastError); const lastError = this.toolsService.toTrimmedString(container.lastError);
@ -663,7 +663,7 @@ export class QqbotAccountService {
private async tryAutoLogin( private async tryAutoLogin(
account: QqbotAccount, account: QqbotAccount,
container: QqbotNapcatContainer, container: NapcatContainer,
) { ) {
try { try {
const result = await this.napcatContainerService.tryAutoLogin(container, { const result = await this.napcatContainerService.tryAutoLogin(container, {
@ -693,7 +693,7 @@ export class QqbotAccountService {
private async applyNapcatOfflineState( private async applyNapcatOfflineState(
account: QqbotAccount, account: QqbotAccount,
container: QqbotNapcatContainer, container: NapcatContainer,
offlineReason: string, offlineReason: string,
) { ) {
await this.markQqLoginOffline(account.selfId, offlineReason); 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; if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null;
const reason = this.toolsService.toTrimmedString(container.lastError); const reason = this.toolsService.toTrimmedString(container.lastError);
return this.toolsService.isNapcatOfflineLoginMessage(reason) return this.toolsService.isNapcatOfflineLoginMessage(reason)
@ -714,7 +714,7 @@ export class QqbotAccountService {
private isRecentConnectNewerThanRuntimeCheck( private isRecentConnectNewerThanRuntimeCheck(
account: QqbotAccount, account: QqbotAccount,
container: QqbotNapcatContainer, container: NapcatContainer,
) { ) {
const checkedAt = this.toTime(container.lastCheckedAt); const checkedAt = this.toTime(container.lastCheckedAt);
if (!checkedAt) return false; 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 { QqbotCommandMatchResult } from '../../contract/qqbot.types';
import type { QqbotNormalizedMessage } 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'; import type { QqbotCommand } from '../../infrastructure/persistence/command/qqbot-command.entity';
@Injectable() @Injectable()
export class QqbotCommandParserService { export class QqbotCommandParserService {
constructor(
@Optional()
@Inject(QQBOT_PLUGIN_EXECUTION_PORT)
private readonly pluginExecution?: QqbotPluginExecutionPort,
) {}
async match(command: QqbotCommand, message: QqbotNormalizedMessage) { async match(command: QqbotCommand, message: QqbotNormalizedMessage) {
const source = `${message.messageText || ''}`.trim(); const source = `${message.messageText || ''}`.trim();
if (!source) return null; if (!source) return null;
const aliases = this.getAliases(command); const aliases = await this.getAliases(command);
const prefixes = this.getPrefixes(command); const prefixes = this.getPrefixes(command);
for (const alias of aliases) { for (const alias of aliases) {
for (const prefix of prefixes) { for (const prefix of prefixes) {
@ -27,8 +37,11 @@ export class QqbotCommandParserService {
return null; return null;
} }
getAliases(command: QqbotCommand) { async getAliases(command: QqbotCommand) {
return this.normalizeList(command.aliases, [command.code, command.name]); return this.mergeLists(
await this.getManifestAliases(command),
this.normalizeList(command.aliases, [command.code, command.name]),
);
} }
getPrefixes(command: QqbotCommand) { getPrefixes(command: QqbotCommand) {
@ -63,6 +76,29 @@ export class QqbotCommandParserService {
return [...new Set(list)]; 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) { private tryParseJsonArray(value: string) {
if (!value.startsWith('[')) return []; if (!value.startsWith('[')) return [];
try { try {

View File

@ -6,7 +6,6 @@ import {
HttpStatus, HttpStatus,
Post, Post,
Query, Query,
Sse,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
@ -15,12 +14,9 @@ import { vbenSuccess } from '@/common';
import { import {
QqbotAccountBodyDto, QqbotAccountBodyDto,
QqbotAccountQueryDto, QqbotAccountQueryDto,
QqbotAccountScanCaptchaDto,
QqbotAccountScanStatusDto,
QqbotAccountUpdateDto, QqbotAccountUpdateDto,
} from './qqbot-account.dto'; } from './qqbot-account.dto';
import { QqbotAccountService } from '../../application/account/qqbot-account.service'; 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'; import { QqbotReverseWsService } from '../../infrastructure/integration/connection/qqbot-reverse-ws.service';
@ApiTags('QQBot - 账号连接') @ApiTags('QQBot - 账号连接')
@ -29,7 +25,6 @@ import { QqbotReverseWsService } from '../../infrastructure/integration/connecti
export class QqbotAccountController { export class QqbotAccountController {
constructor( constructor(
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly napcatLoginService: QqbotNapcatLoginService,
private readonly reverseWsService: QqbotReverseWsService, private readonly reverseWsService: QqbotReverseWsService,
) {} ) {}
@ -59,58 +54,6 @@ export class QqbotAccountController {
return vbenSuccess(await this.accountService.update(body)); 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') @Post('delete')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除 QQBot 账号' }) @ApiOperation({ summary: '删除 QQBot 账号' })

View File

@ -45,19 +45,3 @@ export class QqbotAccountQueryDto {
@ApiPropertyOptional() @ApiPropertyOptional()
connectStatus?: string; 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> = { export type QqbotPluginOperation<Input = any, Output = any> = {
aliases?: string[];
cacheTtlMs?: number; cacheTtlMs?: number;
description?: string; description?: string;
inputSchema?: Record<string, any>; inputSchema?: Record<string, any>;
key: string; key: string;
name: string; name: string;
outputSchema?: Record<string, any>; outputSchema?: Record<string, any>;
timeoutMs?: number;
execute: ( execute: (
input: Input, input: Input,
context: QqbotPluginOperationContext, context: QqbotPluginOperationContext,
@ -206,6 +208,7 @@ export type QqbotPluginSummary = {
}; };
export type QqbotPluginOperationSummary = { export type QqbotPluginOperationSummary = {
aliases?: string[];
cacheTtlMs?: number; cacheTtlMs?: number;
description?: string; description?: string;
inputSchema?: Record<string, any>; inputSchema?: Record<string, any>;
@ -213,6 +216,7 @@ export type QqbotPluginOperationSummary = {
name: string; name: string;
outputSchema?: Record<string, any>; outputSchema?: Record<string, any>;
pluginKey: string; pluginKey: string;
timeoutMs?: number;
triggerMode: QqbotPluginTriggerMode; triggerMode: QqbotPluginTriggerMode;
}; };

View File

@ -3,13 +3,12 @@ import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module'; import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
import { DictModule } from '@/modules/admin/platform-config/dict/dict.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 { QqbotPluginPlatformModule } from '@/modules/qqbot/plugin-platform/plugin-platform.module';
import { QqbotAccountAbility } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account-ability.entity'; 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 { QqbotAccountController } from '@/modules/qqbot/core/contract/account/qqbot-account.controller';
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; 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 { QqbotCommandController } from '@/modules/qqbot/core/contract/command/qqbot-command.controller';
import { QqbotCommand } from '@/modules/qqbot/core/infrastructure/persistence/command/qqbot-command.entity'; import { QqbotCommand } from '@/modules/qqbot/core/infrastructure/persistence/command/qqbot-command.entity';
import { QqbotCommandEngineService } from '@/modules/qqbot/core/application/command/qqbot-command-engine.service'; 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 { QqbotDedupe } from '@/modules/qqbot/core/infrastructure/persistence/dedupe/qqbot-dedupe.entity';
import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service'; import { QqbotDedupeService } from '@/modules/qqbot/core/application/dedupe/qqbot-dedupe.service';
import { QqbotEventService } from '@/modules/qqbot/core/application/event/qqbot-event.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 { QqbotConversation } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-conversation.entity';
import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller'; import { QqbotMessageController } from '@/modules/qqbot/core/contract/message/qqbot-message.controller';
import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity'; import { QqbotMessage } from '@/modules/qqbot/core/infrastructure/persistence/message/qqbot-message.entity';
import { QqbotMessageService } from '@/modules/qqbot/core/application/message/qqbot-message.service'; import { QqbotMessageService } from '@/modules/qqbot/core/application/message/qqbot-message.service';
import { QqbotBusService } from '@/modules/qqbot/core/infrastructure/integration/bus/qqbot-bus.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 { QqbotAllowlist } from '@/modules/qqbot/core/infrastructure/persistence/permission/qqbot-allowlist.entity';
import { QqbotBlocklist } from '@/modules/qqbot/core/infrastructure/persistence/permission/qqbot-blocklist.entity'; import { QqbotBlocklist } from '@/modules/qqbot/core/infrastructure/persistence/permission/qqbot-blocklist.entity';
import { QqbotPermissionController } from '@/modules/qqbot/core/contract/permission/qqbot-permission.controller'; import { QqbotPermissionController } from '@/modules/qqbot/core/contract/permission/qqbot-permission.controller';
@ -67,14 +55,6 @@ export const QQBOT_CORE_ENTITIES = [
QqbotConversation, QqbotConversation,
QqbotDedupe, QqbotDedupe,
QqbotMessage, QqbotMessage,
NapcatAccountBinding,
NapcatContainer,
NapcatDeviceIdentity,
NapcatLoginChallengeEntity,
NapcatLoginSession,
NapcatRuntimeCleanup,
QqbotAccountNapcat,
QqbotNapcatContainer,
QqbotRule, QqbotRule,
QqbotSendLog, QqbotSendLog,
]; ];
@ -100,11 +80,6 @@ export const QQBOT_CORE_PROVIDERS = [
QqbotDedupeService, QqbotDedupeService,
QqbotEventService, QqbotEventService,
QqbotMessageService, QqbotMessageService,
NapcatDeviceIdentityService,
NapcatLoginStateStoreService,
QqbotNapcatLoginService,
QqbotNapcatWatchdogService,
QqbotNapcatContainerService,
QqbotPermissionService, QqbotPermissionService,
QqbotRateLimitService, QqbotRateLimitService,
QqbotReplyTemplateService, QqbotReplyTemplateService,
@ -117,9 +92,6 @@ export const QQBOT_CORE_PROVIDERS = [
export const QQBOT_CORE_EXPORTS = [ export const QQBOT_CORE_EXPORTS = [
QqbotAccountService, QqbotAccountService,
QqbotSendService, QqbotSendService,
NapcatDeviceIdentityService,
QqbotNapcatLoginService,
QqbotNapcatContainerService,
QqbotReverseWsService, QqbotReverseWsService,
]; ];
@ -128,6 +100,7 @@ export const QQBOT_CORE_EXPORTS = [
ConfigModule, ConfigModule,
AdminAuthGuardModule, AdminAuthGuardModule,
DictModule, DictModule,
forwardRef(() => QqbotNapcatModule),
forwardRef(() => QqbotPluginPlatformModule), forwardRef(() => QqbotPluginPlatformModule),
TypeOrmModule.forFeature(QQBOT_CORE_ENTITIES), 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 { createHash, randomUUID } from 'crypto';
import { Injectable, Optional } from '@nestjs/common'; import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { throwVbenError, ToolsService } from '@/common'; 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 { import type {
NapcatApiResponse,
NapcatCaptchaLoginResult, NapcatCaptchaLoginResult,
NapcatCredential,
NapcatLoginInfo, NapcatLoginInfo,
NapcatLoginStatus, NapcatLoginStatus,
NapcatQrcode, NapcatQrcode,
@ -60,10 +59,9 @@ export class QqbotNapcatLoginService {
delete this.sessionEventListenerCache[sessionId]; delete this.sessionEventListenerCache[sessionId];
}), }),
}; };
private readonly credentials: Record< private readonly webuiClient = new NapcatWebuiHttpClient({
string, getTimeoutMs: () => this.getTimeout(),
{ credential: string; expiresAt: number } | undefined });
> = {};
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@ -1274,8 +1272,12 @@ export class QqbotNapcatLoginService {
path: string, path: string,
body: Record<string, any> = {}, body: Record<string, any> = {},
) { ) {
const credential = await this.getCredential(container); return this.webuiClient
return this.requestNapcat<T>(container, path, body, credential); .post<T>(container, path, body)
.catch((err): never => {
const message = this.toolsService.getErrorMessage(err);
return throwVbenError(message || 'NapCat 请求失败');
});
} }
private async prepareReloginQrcode( private async prepareReloginQrcode(
@ -1866,7 +1868,7 @@ export class QqbotNapcatLoginService {
return; return;
} }
delete this.credentials[this.getCredentialCacheKey(container)]; this.webuiClient.clearCredential(container);
if (options.waitForReady === false) return; if (options.waitForReady === false) return;
onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动'); 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; if (options.waitForReady === false) return;
await this.toolsService.sleep(this.getRestartDelayMs()); await this.toolsService.sleep(this.getRestartDelayMs());
await this.getLoginStatus(container, true); 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() { private getSessionTtlMs() {
return Number( return Number(
this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000, 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-account-binding.entity';
export * from './infrastructure/persistence/napcat-container.entity'; export * from './infrastructure/persistence/napcat-container.entity';
export * from './infrastructure/persistence/napcat-device-identity.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-login-state-store.service';
export * from './infrastructure/persistence/napcat-runtime-cleanup.entity'; export * from './infrastructure/persistence/napcat-runtime-cleanup.entity';
export * from './infrastructure/integration/device/napcat-device-identity.service'; 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 './infrastructure/integration/napcat-login-api.client';
export * from './domain/login/napcat-login-state-machine'; export * from './domain/login/napcat-login-state-machine';
export * from './application/login/qqbot-napcat-login.service'; export * from './application/login/qqbot-napcat-login.service';

View File

@ -13,8 +13,8 @@ import {
} from './napcat-docker-device-options'; } from './napcat-docker-device-options';
import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.service'; import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.service';
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { QqbotAccountNapcat } from '../../persistence/qqbot-account-napcat.entity'; import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
import { QqbotNapcatContainer } from '../../persistence/qqbot-napcat-container.entity'; import { NapcatContainer } from '../../persistence/napcat-container.entity';
import type { import type {
NapcatApiResponse, NapcatApiResponse,
NapcatCredential, NapcatCredential,
@ -52,10 +52,10 @@ type NapcatAutoLoginResult = {
export class QqbotNapcatContainerService { export class QqbotNapcatContainerService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@InjectRepository(QqbotNapcatContainer) @InjectRepository(NapcatContainer)
private readonly containerRepository: Repository<QqbotNapcatContainer>, private readonly containerRepository: Repository<NapcatContainer>,
@InjectRepository(QqbotAccountNapcat) @InjectRepository(NapcatAccountBinding)
private readonly bindingRepository: Repository<QqbotAccountNapcat>, private readonly bindingRepository: Repository<NapcatAccountBinding>,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
private readonly deviceIdentityService?: NapcatDeviceIdentityService, private readonly deviceIdentityService?: NapcatDeviceIdentityService,
) {} ) {}
@ -169,7 +169,7 @@ export class QqbotNapcatContainerService {
} }
private async resolveRuntimeDeviceIdentity( private async resolveRuntimeDeviceIdentity(
container: QqbotNapcatContainer, container: NapcatContainer,
selfId: string, selfId: string,
): Promise<NapcatDockerDeviceOptions | undefined> { ): Promise<NapcatDockerDeviceOptions | undefined> {
if ( if (
@ -199,7 +199,7 @@ export class QqbotNapcatContainerService {
} }
async tryAutoLogin( async tryAutoLogin(
container: QqbotNapcatContainer, container: NapcatContainer,
options: NapcatLoginEnvOptions, options: NapcatLoginEnvOptions,
): Promise<NapcatAutoLoginResult> { ): Promise<NapcatAutoLoginResult> {
const selfId = this.toolsService.toTrimmedString(options.selfId); 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({ const existing = await this.bindingRepository.findOne({
where: { where: {
accountId, accountId,
containerId,
isDeleted: false,
}, },
}); });
if (existing) { if (existing) {
@ -343,8 +341,11 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
{ id: existing.id }, { id: existing.id },
{ {
bindStatus: 'bound', bindStatus: 'bound',
containerId,
isPrimary: true, isPrimary: true,
isDeleted: false,
lastLoginAt: new Date(), lastLoginAt: new Date(),
remark: '',
}, },
); );
await this.removeOtherAccountContainers(accountId, containerId); await this.removeOtherAccountContainers(accountId, containerId);
@ -475,7 +476,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
return true; return true;
} }
async detectRuntimeOffline(container: QqbotNapcatContainer) { async detectRuntimeOffline(container: NapcatContainer) {
if (this.getManagedMode() !== 'ssh' || !container.name) return null; if (this.getManagedMode() !== 'ssh' || !container.name) return null;
try { try {
@ -549,7 +550,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
} }
async inspectRuntimeStatus( async inspectRuntimeStatus(
container: QqbotNapcatContainer, container: NapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot> { ): Promise<QqbotNapcatRuntimeStatusSnapshot> {
const checkedAt = new Date(); const checkedAt = new Date();
const containerOnline = container.status === 'running'; 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); const script = this.buildRemoteRemoveScript(container);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
} }
private buildRemoteRemoveScript(container: QqbotNapcatContainer) { private buildRemoteRemoveScript(container: NapcatContainer) {
const dataDir = this.sh(container.dataDir || ''); const dataDir = this.sh(container.dataDir || '');
const name = this.sh(container.name); const name = this.sh(container.name);
const rootDir = this.sh(this.getRootDir()); 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 dataDir = this.sh(container.dataDir || '');
const name = this.sh(container.name); const name = this.sh(container.name);
const rootDir = this.sh(this.getRootDir()); 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); 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( const container = await this.containerRepository.save(
this.containerRepository.create({ this.containerRepository.create({
baseUrl, baseUrl,
accountId: accountId || null,
dataDir, dataDir,
image, image,
isDeleted: false, isDeleted: false,
@ -1194,7 +1196,7 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
}; };
} }
private toRuntime(container: QqbotNapcatContainer): QqbotNapcatRuntime { private toRuntime(container: NapcatContainer): QqbotNapcatRuntime {
return { return {
baseUrl: this.normalizeBaseUrl(container.baseUrl), baseUrl: this.normalizeBaseUrl(container.baseUrl),
id: container.id, 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'; import * as QRCode from 'qrcode';
export type NewDeviceQrStatus = export type NewDeviceQrStatus =
@ -33,6 +36,142 @@ export type NapcatLoginApiTransport = {
post(path: string, body: Record<string, unknown>): Promise<unknown>; 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 { export class NapcatLoginApiClient {
constructor(private readonly transport: NapcatLoginApiTransport) {} 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 { NapcatLoginChallengeEntity } from './napcat-login-challenge.entity';
import { NapcatLoginSession } from './napcat-login-session.entity'; import { NapcatLoginSession } from './napcat-login-session.entity';
import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.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 = { export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
tables: [ tables: [
@ -25,6 +23,4 @@ export const NAPCAT_RUNTIME_ENTITIES = [
NapcatLoginChallengeEntity, NapcatLoginChallengeEntity,
NapcatLoginSession, NapcatLoginSession,
NapcatRuntimeCleanup, NapcatRuntimeCleanup,
QqbotAccountNapcat,
QqbotNapcatContainer,
]; ];

View File

@ -3,12 +3,14 @@ import {
ensureSnowflakeId, ensureSnowflakeId,
KtCreateDateColumn, KtCreateDateColumn,
KtDateTime, KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn, KtUpdateDateColumn,
} from '@/common'; } from '@/common';
import type { QqbotAccountNapcatBindStatus } from '@/modules/qqbot/core/contract/qqbot.types'; import type { QqbotAccountNapcatBindStatus } from '@/modules/qqbot/core/contract/qqbot.types';
@Entity('napcat_account_binding') @Entity('napcat_account_binding')
@Index('uk_napcat_account_binding_account', ['accountId'], { unique: true }) @Index('uk_napcat_account_binding_account', ['accountId'], { unique: true })
@Index('idx_napcat_account_binding_container', ['containerId', 'isDeleted'])
export class NapcatAccountBinding { export class NapcatAccountBinding {
@PrimaryColumn({ type: 'bigint' }) @PrimaryColumn({ type: 'bigint' })
id: string; id: string;
@ -19,11 +21,33 @@ export class NapcatAccountBinding {
@Column({ name: 'container_id', type: 'bigint' }) @Column({ name: 'container_id', type: 'bigint' })
containerId: string; containerId: string;
@Column({ name: 'device_identity_id', type: 'bigint' }) @Column({
deviceIdentityId: string; default: null,
name: 'device_identity_id',
nullable: true,
type: 'bigint',
})
deviceIdentityId: null | string;
@Column({ length: 32 }) @Column({ default: 'pending', length: 32, name: 'status' })
status: QqbotAccountNapcatBindStatus; 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' }) @KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime; createTime: KtDateTime;

View File

@ -3,28 +3,77 @@ import {
ensureSnowflakeId, ensureSnowflakeId,
KtCreateDateColumn, KtCreateDateColumn,
KtDateTime, KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn, KtUpdateDateColumn,
} from '@/common'; } from '@/common';
import type { QqbotNapcatContainerStatus } from '@/modules/qqbot/core/contract/qqbot.types'; import type { QqbotNapcatContainerStatus } from '@/modules/qqbot/core/contract/qqbot.types';
@Entity('napcat_container') @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 { export class NapcatContainer {
@PrimaryColumn({ type: 'bigint' }) @PrimaryColumn({ type: 'bigint' })
id: string; id: string;
@Column({ name: 'account_id', type: 'bigint' }) @Column({ name: 'account_id', nullable: true, type: 'bigint' })
accountId: string; accountId: null | string;
@Column({ length: 128, name: 'container_name' }) @Column({ length: 120, name: 'container_name' })
containerName: string; name: string;
@Column({ length: 255, name: 'image_name' }) @Column({ length: 255, name: 'base_url' })
imageName: string; 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; 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' }) @KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime; createTime: KtDateTime;

View File

@ -55,8 +55,11 @@ export class NapcatLoginStateStoreService {
where: { sessionKey: sessionId }, where: { sessionKey: sessionId },
}); });
const session = persisted?.sessionPayload; const session = persisted?.sessionPayload;
if (session) this.cache[session.id] = session; if (!session) return undefined;
return session;
const hydratedSession = await this.hydratePersistedSession(session);
this.cache[hydratedSession.id] = hydratedSession;
return hydratedSession;
} }
set(session: QqbotLoginScanSession) { 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: { private async saveChallenge(input: {
challengePayload: null | Record<string, unknown>; challengePayload: null | Record<string, unknown>;
challengeType: NapcatLoginChallengeType; 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 # QQBot NapCat Schema
NapCat owns container runtime, account binding, device identity, login session, NapCat owns container runtime, account binding, device identity, login session,
challenge, and cleanup persistence. challenge, and cleanup persistence through the v3 `napcat_*` tables.
Current compatibility tables:
- `qqbot_account_napcat`
- `qqbot_napcat_container`
- `napcat_device_identity`
Target v3 tables:
- `napcat_container` - `napcat_container`
- `napcat_device_identity` - `napcat_device_identity`
@ -21,7 +13,7 @@ Target v3 tables:
Verification SQL: Verification SQL:
```sql ```sql
SELECT COUNT(*) FROM napcat_container WHERE is_deleted = 0;
SELECT COUNT(*) FROM napcat_device_identity; SELECT COUNT(*) FROM napcat_device_identity;
SELECT COUNT(*) FROM qqbot_account_napcat WHERE is_deleted = 0; SELECT COUNT(*) FROM napcat_account_binding WHERE is_deleted = 0;
SELECT COUNT(*) FROM qqbot_napcat_container WHERE is_deleted = 0;
``` ```

View File

@ -13,6 +13,9 @@ import {
type QqbotPluginManifest, type QqbotPluginManifest,
} from '../domain/manifest'; } from '../domain/manifest';
import type { QqbotPluginWorkerRuntime } from '../infrastructure/integration/runtime'; 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 { import {
QqbotPlugin, QqbotPlugin,
QqbotPluginAccountBinding, QqbotPluginAccountBinding,
@ -106,6 +109,12 @@ export class QqbotPluginPlatformService {
@Optional() @Optional()
@Inject(QQBOT_PLUGIN_RUNTIME_FACTORY) @Inject(QQBOT_PLUGIN_RUNTIME_FACTORY)
private readonly runtimeFactory?: QqbotPluginRuntimeFactory, private readonly runtimeFactory?: QqbotPluginRuntimeFactory,
@Optional()
private readonly pluginRegistry?: QqbotPluginRegistryService,
@Optional()
private readonly eventPluginRegistry?: QqbotEventPluginRegistryService,
@Optional()
private readonly packageReader?: QqbotPluginPackageReaderService,
) {} ) {}
async listInstallations() { async listInstallations() {
@ -163,8 +172,16 @@ export class QqbotPluginPlatformService {
}; };
} }
uploadPackage(body: InstallLocalBody) {
return {
...this.requirePackageReader().readPackage(body),
valid: true,
};
}
async installLocal(body: InstallLocalBody) { 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({ const plugin = await this.pluginRepository.save({
pluginKey: manifest.pluginKey, pluginKey: manifest.pluginKey,
pluginName: manifest.name, pluginName: manifest.name,
@ -173,7 +190,7 @@ export class QqbotPluginPlatformService {
}); });
const version = await this.versionRepository.save({ const version = await this.versionRepository.save({
manifestJson: manifest, manifestJson: manifest,
packageHash: body.packageHash || 'local-dev-package', packageHash: pluginPackage.packageHash,
pluginId: plugin.id, pluginId: plugin.id,
version: manifest.version, version: manifest.version,
}); });
@ -181,7 +198,7 @@ export class QqbotPluginPlatformService {
await this.persistManifestCapabilities(plugin.id, manifest); await this.persistManifestCapabilities(plugin.id, manifest);
return this.installationRepository.save({ return this.installationRepository.save({
installedPath: body.packagePath || '', installedPath: pluginPackage.packagePath,
pluginId: plugin.id, pluginId: plugin.id,
runtimeStatus: 'stopped', runtimeStatus: 'stopped',
status: 'installed', status: 'installed',
@ -414,6 +431,7 @@ export class QqbotPluginPlatformService {
) { ) {
const activeOperation = enabled; const activeOperation = enabled;
const activeEvent = enabled; const activeEvent = enabled;
const pluginKey = await this.getPluginKey(installation.pluginId);
await Promise.all([ await Promise.all([
this.operationRepository.update( this.operationRepository.update(
{ pluginId: installation.pluginId }, { pluginId: installation.pluginId },
@ -424,6 +442,16 @@ export class QqbotPluginPlatformService {
{ enabled: activeEvent }, { 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) { private async stopWorker(installationId: string) {
@ -501,4 +529,11 @@ export class QqbotPluginPlatformService {
} }
return {}; 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 { formatKtDateTime, throwVbenError } from '@/common';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import type { import type {
@ -9,36 +11,75 @@ import type {
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import { import {
QqbotBuiltinPluginPackageLoaderService, QqbotBuiltinPluginPackageLoaderService,
type QqbotRepeaterPluginPackage, type QqbotEventPluginPackage,
} from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; } 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() @Injectable()
export class QqbotEventPluginRegistryService { export class QqbotEventPluginRegistryService implements OnModuleInit {
private repeaterPlugin?: QqbotRepeaterPluginPackage; private readonly eventPlugins = new Map<string, QqbotEventPluginPackage>();
private readonly inactivePluginKeys = new Set<string>();
constructor( constructor(
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly builtinPluginLoader: QqbotBuiltinPluginPackageLoaderService, 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[] { listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] {
return this.getDefinitions(pluginKey); return this.getDefinitions(pluginKey);
} }
setPluginActive(pluginKey: string, active: boolean) {
if (active) {
this.inactivePluginKeys.delete(pluginKey);
return;
}
this.inactivePluginKeys.add(pluginKey);
}
async listPlugins(selfId?: string) { async listPlugins(selfId?: string) {
const plugins = this.getActiveEventPlugins();
const accounts = selfId const accounts = selfId
? [await this.accountService.findBySelfId(selfId)] ? [await this.accountService.findBySelfId(selfId)]
: await this.accountService.allEnabled(); : await this.accountService.allEnabled();
return Promise.all( return Promise.all(
accounts accounts
.filter((account): account is NonNullable<typeof account> => !!account) .filter((account): account is NonNullable<typeof account> => !!account)
.map((account) => .flatMap((account) =>
this.getRepeaterPlugin().getSummary({ plugins.map((plugin) =>
plugin.getSummary({
accountName: account.name, accountName: account.name,
connectStatus: account.connectStatus, connectStatus: account.connectStatus,
selfId: account.selfId, selfId: account.selfId,
}), }),
), ),
),
); );
} }
@ -68,41 +109,80 @@ export class QqbotEventPluginRegistryService {
} }
async dispatchMessage(message: QqbotNormalizedMessage) { 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) { async bind(pluginKey: string, selfId: string) {
this.assertPlugin(pluginKey); return this.requirePlugin(pluginKey).bind(selfId);
if (pluginKey === 'repeater') {
return this.getRepeaterPlugin().bind(selfId);
}
await this.accountService.bindEventPlugin(selfId, pluginKey);
return true;
} }
async unbind(pluginKey: string, selfId: string) { async unbind(pluginKey: string, selfId: string) {
this.assertPlugin(pluginKey); return this.requirePlugin(pluginKey).unbind(selfId);
if (pluginKey === 'repeater') {
return this.getRepeaterPlugin().unbind(selfId);
}
await this.accountService.unbindEventPlugin(selfId, pluginKey);
return true;
} }
private assertPlugin(pluginKey: string) { private requirePlugin(pluginKey: string) {
if (pluginKey === 'repeater') return; this.ensureEventPluginsLoaded();
const plugin = this.eventPlugins.get(pluginKey);
if (!plugin) {
throwVbenError(`QQBot 事件插件不存在:${pluginKey}`); throwVbenError(`QQBot 事件插件不存在:${pluginKey}`);
} }
if (!this.isPluginActive(pluginKey)) {
throwVbenError(`QQBot 事件插件未启用:${pluginKey}`);
}
return plugin;
}
private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] {
const definitions = [this.getRepeaterPlugin().getDefinition()]; const definitions = this.getActiveEventPlugins().map((plugin) =>
plugin.getDefinition(),
);
return pluginKey return pluginKey
? definitions.filter((definition) => definition.key === pluginKey) ? definitions.filter((definition) => definition.key === pluginKey)
: definitions; : definitions;
} }
private getRepeaterPlugin() { private isPluginActive(pluginKey: string) {
this.repeaterPlugin ||= this.builtinPluginLoader.loadRepeaterPlugin(); return !this.inactivePluginKeys.has(pluginKey);
return this.repeaterPlugin; }
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 { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { formatKtDateTime, throwVbenError } from '@/common'; import { formatKtDateTime, throwVbenError } from '@/common';
import type { import type {
QqbotIntegrationPlugin, QqbotIntegrationPlugin,
@ -8,21 +10,34 @@ import type {
QqbotPluginSummary, QqbotPluginSummary,
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import { QqbotBuiltinPluginPackageLoaderService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; 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() @Injectable()
export class QqbotPluginRegistryService implements OnModuleInit { export class QqbotPluginRegistryService implements OnModuleInit {
private readonly inactivePluginKeys = new Set<string>();
private readonly pluginAliases = new Map<string, string>(); private readonly pluginAliases = new Map<string, string>();
private readonly plugins = new Map<string, QqbotIntegrationPlugin>(); private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
constructor( constructor(
@Optional() @Optional()
private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService, 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() || []) { for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
this.register(plugin); this.register(plugin);
} }
await this.hydrateInactivePluginKeys();
} }
register(plugin: QqbotIntegrationPlugin) { register(plugin: QqbotIntegrationPlugin) {
@ -39,8 +54,19 @@ 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[] { listPlugins(): QqbotPluginSummary[] {
return [...this.plugins.values()].map((plugin) => ({ return [...this.plugins.values()]
.filter((plugin) => this.isPluginActive(plugin.key))
.map((plugin) => ({
description: plugin.description, description: plugin.description,
key: plugin.key, key: plugin.key,
name: plugin.name, name: plugin.name,
@ -53,6 +79,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
listOperations(pluginKey?: string): QqbotPluginOperationSummary[] { listOperations(pluginKey?: string): QqbotPluginOperationSummary[] {
return this.getPlugins(pluginKey).flatMap((plugin) => return this.getPlugins(pluginKey).flatMap((plugin) =>
plugin.operations.map((operation) => ({ plugin.operations.map((operation) => ({
aliases: operation.aliases,
cacheTtlMs: operation.cacheTtlMs, cacheTtlMs: operation.cacheTtlMs,
description: operation.description, description: operation.description,
inputSchema: operation.inputSchema, inputSchema: operation.inputSchema,
@ -60,6 +87,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
name: operation.name, name: operation.name,
outputSchema: operation.outputSchema, outputSchema: operation.outputSchema,
pluginKey: plugin.key, pluginKey: plugin.key,
timeoutMs: operation.timeoutMs,
triggerMode: 'command', triggerMode: 'command',
})), })),
); );
@ -96,7 +124,7 @@ export class QqbotPluginRegistryService implements OnModuleInit {
context: QqbotPluginOperationContext = {}, context: QqbotPluginOperationContext = {},
) { ) {
const operation = this.getOperation(pluginKey, operationKey); const operation = this.getOperation(pluginKey, operationKey);
return operation.execute(input, context); return this.executeWithTimeout(operation, input, context);
} }
assertOperation(pluginKey?: string, operationKey?: string) { assertOperation(pluginKey?: string, operationKey?: string) {
@ -109,6 +137,9 @@ export class QqbotPluginRegistryService implements OnModuleInit {
private getOperation(pluginKey: string, operationKey: string) { private getOperation(pluginKey: string, operationKey: string) {
const plugin = this.getPluginByKey(pluginKey); const plugin = this.getPluginByKey(pluginKey);
if (!plugin) throwVbenError(`QQBot 插件不存在:${pluginKey}`); if (!plugin) throwVbenError(`QQBot 插件不存在:${pluginKey}`);
if (!this.isPluginActive(plugin.key)) {
throwVbenError(`QQBot 插件未启用:${plugin.key}`);
}
const operation = plugin.operations.find( const operation = plugin.operations.find(
(item) => item.key === operationKey, (item) => item.key === operationKey,
@ -119,10 +150,44 @@ export class QqbotPluginRegistryService implements OnModuleInit {
return operation; 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) { 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); const plugin = this.getPluginByKey(pluginKey);
return plugin ? [plugin] : []; return plugin && this.isPluginActive(plugin.key) ? [plugin] : [];
} }
private getPluginByKey(pluginKey: string) { private getPluginByKey(pluginKey: string) {
@ -131,4 +196,27 @@ export class QqbotPluginRegistryService implements OnModuleInit {
this.plugins.get(this.pluginAliases.get(pluginKey) || '') 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; packagePath?: string;
}, },
) { ) {
return vbenSuccess(this.service.validateManifest(body)); return vbenSuccess(this.service.uploadPackage(body));
} }
@Post('install') @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 { DictService } from '@/modules/admin/platform-config/dict/dict.service';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.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 { import {
parseQqbotPluginManifest, parseQqbotPluginManifest,
type QqbotPluginManifest, type QqbotPluginManifest,
} from '@/modules/qqbot/plugin-platform/domain/manifest'; } from '@/modules/qqbot/plugin-platform/domain/manifest';
import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk'; import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk';
import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src'; 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 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 createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src';
import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src';
import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src';
export type QqbotRepeaterPluginPackage = ReturnType< export type RepeaterPluginPackage = ReturnType<
typeof createRepeaterPlugin 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() @Injectable()
export class QqbotBuiltinPluginPackageLoaderService { export class QqbotBuiltinPluginPackageLoaderService {
private readonly logger = new Logger( 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'); const manifest = this.loadManifest('repeater');
return createRepeaterPlugin({ return createRepeaterPlugin({
host: { host: {
@ -86,13 +116,15 @@ export class QqbotBuiltinPluginPackageLoaderService {
normalizeError: (error) => normalizeError: (error) =>
this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'), this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'),
operations: manifest.operations.map((operation) => ({ operations: manifest.operations.map((operation) => ({
aliases: operation.aliases,
description: operation.description, description: operation.description,
handlerName: operation.handlerName, handlerName: operation.handlerName as BangDreamOperationHandlerName,
inputSchema: operation.inputSchema, inputSchema: operation.inputSchema,
key: operation.key, key: operation.key as BangDreamOperationKey,
name: operation.name, name: operation.name,
outputSchema: operation.outputSchema, outputSchema: operation.outputSchema,
})) as BangDreamManifestOperation[], timeoutMs: operation.timeoutMs,
})),
pluginKey: manifest.pluginKey, pluginKey: manifest.pluginKey,
version: manifest.version, version: manifest.version,
}) as QqbotIntegrationPlugin; }) as QqbotIntegrationPlugin;
@ -120,6 +152,7 @@ export class QqbotBuiltinPluginPackageLoaderService {
host: { host: {
getConfig: (key) => this.configService.get(key), getConfig: (key) => this.configService.get(key),
getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode), getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode),
resolveKnownWorld: (candidate) => this.resolveKnownFf14World(candidate),
requestJson: (options) => this.httpClient.requestJson(options), requestJson: (options) => this.httpClient.requestJson(options),
}, },
manifest, 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) { private resolvePluginRoot(pluginKey: string) {
const sourceRoot = join( const sourceRoot = join(
process.cwd(), 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';
export * from './worker-runtime.types'; 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 { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port';
import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service'; 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 { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service';
import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service';
import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter'; 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 { QqbotPluginPlatformController } from './contract/plugin-platform.controller';
import { QqbotPluginController } from './contract/qqbot-plugin.controller'; import { QqbotPluginController } from './contract/qqbot-plugin.controller';
import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence'; 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 { 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 { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
import { QqbotBuiltinPluginWorkerRuntimeFactoryService } from './infrastructure/integration/runtime';
import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.service';
@Module({ @Module({
controllers: [QqbotPluginController, QqbotPluginPlatformController], controllers: [QqbotPluginController, QqbotPluginPlatformController],
@ -36,16 +37,17 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
QqbotEventPluginRegistryService, QqbotEventPluginRegistryService,
QqbotPluginArgumentParserService, QqbotPluginArgumentParserService,
QqbotPluginExecutionAdapter, QqbotPluginExecutionAdapter,
QqbotPluginInputNormalizerService,
QqbotBuiltinPluginPackageLoaderService, QqbotBuiltinPluginPackageLoaderService,
{ QqbotBuiltinPluginWorkerRuntimeFactoryService,
provide: QQBOT_PLUGIN_INPUT_NORMALIZER, QqbotPluginPackageReaderService,
useExisting: QqbotPluginInputNormalizerService,
},
{ {
provide: QQBOT_PLUGIN_EXECUTION_PORT, provide: QQBOT_PLUGIN_EXECUTION_PORT,
useExisting: QqbotPluginExecutionAdapter, useExisting: QqbotPluginExecutionAdapter,
}, },
{
provide: QQBOT_PLUGIN_RUNTIME_FACTORY,
useExisting: QqbotBuiltinPluginWorkerRuntimeFactoryService,
},
QqbotPluginHttpClientService, QqbotPluginHttpClientService,
QqbotPluginPlatformService, QqbotPluginPlatformService,
QqbotPluginRegistryService, QqbotPluginRegistryService,

View File

@ -12,14 +12,14 @@ import {
fuzzySearch, fuzzySearch,
type FuzzySearchResult, type FuzzySearchResult,
} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search'; } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search';
import mainAPI, { import bangdreamCatalogCache, {
waitForMainDataReady, waitForBangDreamCatalogReady,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store'; } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache';
import type { import type {
QqbotBangDreamCommandInput, BangDreamCommandInput,
QqbotBangDreamCommandOutput, BangDreamCommandOutput,
QqbotBangDreamOperationKey, BangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types'; } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types';
const SOURCE_NAME = 'BangDream 内置插件'; const SOURCE_NAME = 'BangDream 内置插件';
@ -58,8 +58,8 @@ export class BangDreamCommandContext {
} }
async checkHealth() { async checkHealth() {
await waitForMainDataReady(); await waitForBangDreamCatalogReady();
const data = mainAPI as { cards?: unknown; songs?: unknown }; const data = bangdreamCatalogCache as { cards?: unknown; songs?: unknown };
if (!data.songs || !data.cards) { if (!data.songs || !data.cards) {
throw new Error('BangDream 数据配置未加载'); throw new Error('BangDream 数据配置未加载');
} }
@ -79,10 +79,10 @@ export class BangDreamCommandContext {
} }
toImageReply( toImageReply(
operationKey: QqbotBangDreamOperationKey, operationKey: BangDreamOperationKey,
query: string, query: string,
list: Array<Buffer | string>, list: Array<Buffer | string>,
): QqbotBangDreamCommandOutput { ): BangDreamCommandOutput {
const images = list.filter((item): item is Buffer => Buffer.isBuffer(item)); const images = list.filter((item): item is Buffer => Buffer.isBuffer(item));
if (images.length === 0) { if (images.length === 0) {
const message = const message =
@ -102,7 +102,7 @@ export class BangDreamCommandContext {
} }
getRenderOptions( getRenderOptions(
input: QqbotBangDreamCommandInput, input: BangDreamCommandInput,
defaults: { useEasyBG?: boolean } = {}, defaults: { useEasyBG?: boolean } = {},
) { ) {
return { return {
@ -125,7 +125,7 @@ export class BangDreamCommandContext {
}; };
} }
pickDisplayedServerList(input: QqbotBangDreamCommandInput) { pickDisplayedServerList(input: BangDreamCommandInput) {
const source = const source =
input.displayedServerList || input.displayedServerList ||
this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.displayedServers); this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.displayedServers);
@ -138,7 +138,7 @@ export class BangDreamCommandContext {
return servers.length > 0 ? [...new Set(servers)] : defaultServers; return servers.length > 0 ? [...new Set(servers)] : defaultServers;
} }
pickMainServer(input: QqbotBangDreamCommandInput, tokens: string[]): Server { pickMainServer(input: BangDreamCommandInput, tokens: string[]): Server {
const explicit = this.firstDefined( const explicit = this.firstDefined(
input.mainServer, input.mainServer,
input.serverName, input.serverName,
@ -171,24 +171,24 @@ export class BangDreamCommandContext {
return server === undefined ? undefined : (server as Server); return server === undefined ? undefined : (server as Server);
} }
requireText(input: QqbotBangDreamCommandInput, message: string) { requireText(input: BangDreamCommandInput, message: string) {
const text = this.pickText(input); const text = this.pickText(input);
if (!text) throw new Error(message); if (!text) throw new Error(message);
return text; return text;
} }
pickText(input: QqbotBangDreamCommandInput) { pickText(input: BangDreamCommandInput) {
return `${input.query || input.text || input.raw || ''}`.trim(); return `${input.query || input.text || input.raw || ''}`.trim();
} }
getTokens(input: QqbotBangDreamCommandInput) { getTokens(input: BangDreamCommandInput) {
if (Array.isArray(input.args)) { if (Array.isArray(input.args)) {
return input.args.map((item) => `${item}`.trim()).filter(Boolean); return input.args.map((item) => `${item}`.trim()).filter(Boolean);
} }
return this.pickText(input).split(/\s+/).filter(Boolean); return this.pickText(input).split(/\s+/).filter(Boolean);
} }
firstToken(input: QqbotBangDreamCommandInput) { firstToken(input: BangDreamCommandInput) {
return this.getTokens(input)[0]; 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 { 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 type { BangDreamCardArtAttribute } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-art.layout';
import { import {
@ -8,7 +8,7 @@ import {
export class CardArtResourceRepository { export class CardArtResourceRepository {
constructor( 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 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 { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
import { import {
@ -35,7 +35,7 @@ function toServerCode(server: Server | undefined): string {
export class CardResourceRepository { export class CardResourceRepository {
constructor( 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 { Gacha } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model';
import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { Image, loadImage } from 'skia-canvas'; 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 { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; 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'; 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) { constructor(cardId: number) {
this.cardId = cardId; this.cardId = cardId;
const cardData = bangDreamMainDataRepository.getEntity<Record<string, any>>( const cardData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'cards', 'cards',
cardId, cardId,
); );

View File

@ -1,15 +1,15 @@
import { Card } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model'; import { Card } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model';
import { import {
bangDreamMainDataRepository, bangdreamCatalogRepository,
type BangDreamMainDataCollection, type BangDreamCatalogCollection,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository';
export class CardRepository { export class CardRepository {
/** /**
* *
*/ */
getSource(): BangDreamMainDataCollection { getSource(): BangDreamCatalogCollection {
return bangDreamMainDataRepository.getCollection('cards'); 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 { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import { Card, Stat } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model'; import { Card, Stat } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model';
@ -21,7 +21,7 @@ export class AreaItem {
*/ */
constructor(areaItemId: number) { constructor(areaItemId: number) {
this.areaItemId = areaItemId; this.areaItemId = areaItemId;
const areaItemData = mainAPI['areaItems'][areaItemId.toString()]; const areaItemData = bangdreamCatalogCache['areaItems'][areaItemId.toString()];
if (areaItemData == undefined) { if (areaItemData == undefined) {
this.isExist = false; this.isExist = false;
return; 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'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
export class AttributeResourceRepository { export class AttributeResourceRepository {
constructor( 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 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 { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
export class BandResourceRepository { export class BandResourceRepository {
constructor( 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 { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model';
import { Image, loadImage } from 'skia-canvas'; import { Image, loadImage } from 'skia-canvas';
import { convertSvgToPngBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; import { convertSvgToPngBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image';
@ -18,8 +18,8 @@ export class Band {
*/ */
constructor(bandId: number) { constructor(bandId: number) {
this.bandId = bandId; this.bandId = bandId;
const bandData = mainAPI['singer'][bandId.toString()]; const bandData = bangdreamCatalogCache['singer'][bandId.toString()];
if (mainAPI['bands'][bandId.toString()] != undefined) { if (bangdreamCatalogCache['bands'][bandId.toString()] != undefined) {
this.hasIcon = true; this.hasIcon = true;
} }
if (bandData == undefined) { if (bandData == undefined) {
@ -36,7 +36,7 @@ export class Band {
*/ */
getMembers() { getMembers() {
const members = []; const members = [];
const characterList = mainAPI['characters']; const characterList = bangdreamCatalogCache['characters'];
for (const characterID in characterList) { for (const characterID in characterList) {
const character = new Character(parseInt(characterID)); const character = new Character(parseInt(characterID));
if (character.bandId == this.bandId) { 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 type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
import { import {
getServerByPriority, getServerByPriority,
@ -17,7 +17,7 @@ function toServerCode(server: Server | undefined): string {
export class CostumeResourceRepository { export class CostumeResourceRepository {
constructor( 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 { 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 { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import { Image, loadImage } from 'skia-canvas'; import { Image, loadImage } from 'skia-canvas';
import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils'; import { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
@ -23,7 +23,7 @@ export class Costume {
*/ */
constructor(costumeId: number) { constructor(costumeId: number) {
this.costumeId = costumeId; this.costumeId = costumeId;
const costumeData = mainAPI['costumes'][costumeId.toString()]; const costumeData = bangdreamCatalogCache['costumes'][costumeId.toString()];
if (costumeData == undefined) { if (costumeData == undefined) {
this.isExist = false; this.isExist = false;
return; 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 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'; 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 { export class DegreeResourceRepository {
constructor( 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 { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import { Canvas, Image, loadImage } from 'skia-canvas'; 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 { readJSONFromBuffer } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
import { degreeResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository'; import { degreeResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/degree-resource.repository';
@ -20,7 +20,7 @@ export class Degree {
*/ */
constructor(degreeId) { constructor(degreeId) {
this.degreeId = degreeId; this.degreeId = degreeId;
const degreeData = mainAPI['degrees'][degreeId.toString()]; const degreeData = bangdreamCatalogCache['degrees'][degreeId.toString()];
if (degreeData == undefined) { if (degreeData == undefined) {
this.isExist = false; this.isExist = false;
return; 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 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 { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; 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 { export class ItemResourceRepository {
constructor( constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider,
) {} ) {}
/** /**

View File

@ -3,7 +3,7 @@ import {
Server, Server,
getServerByPriority, getServerByPriority,
} from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; } 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 { itemResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/item-resource.repository';
import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; 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'; import { BANGDREAM_ITEM_TYPE_PREFIXES } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol';
@ -47,7 +47,7 @@ export class Item {
return; return;
} }
//如果是其他物品 //如果是其他物品
const itemData = mainAPI['items'][itemId]; const itemData = bangdreamCatalogCache['items'][itemId];
if (itemData == undefined) { if (itemData == undefined) {
return; 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 type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest'; import { getBangDreamAssetPath } from '@/modules/qqbot/plugins/bangdream/src/theme/asset-manifest';
export class ServerResourceRepository { export class ServerResourceRepository {
constructor( 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 { export class Skill {
skillId: number; skillId: number;
@ -18,13 +18,13 @@ export class Skill {
*/ */
constructor(skillId: number) { constructor(skillId: number) {
this.skillId = skillId; this.skillId = skillId;
if (mainAPI['skills'][this.skillId.toString()] == undefined) { if (bangdreamCatalogCache['skills'][this.skillId.toString()] == undefined) {
this.isExist = false; this.isExist = false;
return; return;
} }
this.isExist = true; this.isExist = true;
this.skillId = this.skillId; this.skillId = this.skillId;
this.data = mainAPI['skills'][this.skillId.toString()]; this.data = bangdreamCatalogCache['skills'][this.skillId.toString()];
this.simpleDescription = this.data['simpleDescription']; this.simpleDescription = this.data['simpleDescription'];
this.description = this.data['description']; this.description = this.data['description'];
this.duration = this.data['duration']; 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 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 { formatNumber } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
export class CharacterResourceRepository { export class CharacterResourceRepository {
constructor( 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 { 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 { import {
match, match,
FuzzySearchResult, FuzzySearchResult,
@ -31,7 +31,7 @@ export async function drawCharacterList(
): Promise<Array<Buffer | string>> { ): Promise<Array<Buffer | string>> {
//计算模糊搜索结果 //计算模糊搜索结果
const tempCharacterList: Array<Character> = []; //最终输出的角色列表 const tempCharacterList: Array<Character> = []; //最终输出的角色列表
const characterIdList: Array<number> = Object.keys(mainAPI['characters']).map( const characterIdList: Array<number> = Object.keys(bangdreamCatalogCache['characters']).map(
Number, Number,
); //所有卡牌ID列表 ); //所有卡牌ID列表
for (let i = 0; i < characterIdList.length; i++) { 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 { Image, loadImage } from 'skia-canvas';
import { characterResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository'; import { characterResourceRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-resource.repository';
@ -38,7 +38,7 @@ export class Character {
* @param characterId - ID * @param characterId - ID
*/ */
constructor(characterId: number) { constructor(characterId: number) {
const characterData = mainAPI['characters'][characterId.toString()]; const characterData = bangdreamCatalogCache['characters'][characterId.toString()];
if (characterData == undefined) { if (characterData == undefined) {
this.isExist = false; this.isExist = false;
return; return;

View File

@ -1,4 +1,4 @@
export type QqbotBangDreamOperationKey = export type BangDreamOperationKey =
| 'bangdream.card.illustration' | 'bangdream.card.illustration'
| 'bangdream.card.search' | 'bangdream.card.search'
| 'bangdream.character.search' | 'bangdream.character.search'
@ -15,7 +15,7 @@ export type QqbotBangDreamOperationKey =
| 'bangdream.song.random' | 'bangdream.song.random'
| 'bangdream.song.search'; | 'bangdream.song.search';
export type QqbotBangDreamOperationHandlerName = export type BangDreamOperationHandlerName =
| 'getCardIllustration' | 'getCardIllustration'
| 'getCutoffAll' | 'getCutoffAll'
| 'getCutoffDetail' | 'getCutoffDetail'
@ -32,7 +32,7 @@ export type QqbotBangDreamOperationHandlerName =
| 'searchSong' | 'searchSong'
| 'simulateGacha'; | 'simulateGacha';
export type QqbotBangDreamCommandInput = { export type BangDreamCommandInput = {
args?: string[]; args?: string[];
cardId?: number | string; cardId?: number | string;
compress?: boolean | string; compress?: boolean | string;
@ -55,9 +55,9 @@ export type QqbotBangDreamCommandInput = {
useEasyBG?: boolean | string; useEasyBG?: boolean | string;
}; };
export type QqbotBangDreamCommandOutput = { export type BangDreamCommandOutput = {
imageCount: number; imageCount: number;
operationKey: QqbotBangDreamOperationKey; operationKey: BangDreamOperationKey;
query: string; query: string;
replyText: string; replyText: string;
source: 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 { serverNameFullList } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import { drawEventDataBlock } from '@/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer'; import { drawEventDataBlock } from '@/modules/qqbot/plugins/bangdream/src/theme/detail-block.renderer';
import { statusName } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; 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'; import { BangDreamEventStatus } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol';
/** /**
@ -33,7 +33,7 @@ export async function drawCutoffDetail(
mainServer: Server, mainServer: Server,
compress: boolean, compress: boolean,
): Promise<Array<Buffer | string>> { ): Promise<Array<Buffer | string>> {
const eventData = bangDreamMainDataRepository.getEntity<Record<string, any>>( const eventData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'events', 'events',
eventId, 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 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'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
@ -27,7 +27,7 @@ export interface CutoffEventTopData {
export class CutoffEventTopRepository { export class CutoffEventTopRepository {
constructor( 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 { 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 { 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 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 { import {
preferHhwxSource, preferHhwxSource,
reportDataSourceProblem, reportDataSourceProblem,
@ -91,15 +91,15 @@ export class Cutoff {
if (this.server != Server.cn) { if (this.server != Server.cn) {
// 非国服不使用HHWX // 非国服不使用HHWX
this.useHHWX = false; this.useHHWX = false;
return bangDreamBestdoriProvider; return bangdreamBestdoriProvider;
} }
const provider = !reverse const provider = !reverse
? this.useHHWX ? this.useHHWX
? bangDreamHhwxTrackerProvider ? bangdreamHhwxTrackerProvider
: bangDreamBestdoriProvider : bangdreamBestdoriProvider
: this.useHHWX : this.useHHWX
? bangDreamBestdoriProvider ? bangdreamBestdoriProvider
: bangDreamHhwxTrackerProvider; : bangdreamHhwxTrackerProvider;
if (reverse && this.server == Server.cn) this.useHHWX = !this.useHHWX; if (reverse && this.server == Server.cn) this.useHHWX = !this.useHHWX;
return provider; return provider;
} }
@ -236,7 +236,7 @@ export class Cutoff {
} }
//rate //rate
const rateDataList = const rateDataList =
bangDreamMainDataRepository.getValue< bangdreamCatalogRepository.getValue<
Array<{ server: number; type: string; tier: number; rate: number }> Array<{ server: number; type: string; tier: number; rate: number }>
>('rates'); >('rates');
const rateData = rateDataList.find((element) => { const rateData = rateDataList.find((element) => {

View File

@ -1,5 +1,5 @@
import { Canvas, Image, loadImage } from 'skia-canvas'; 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 { import {
getServerByPriority, getServerByPriority,
Server, Server,
@ -35,7 +35,7 @@ type RewardWithId = {
}; };
export class EventDataRepository { 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'); const rewardId = this.pickRewardId(event.rankingRewards, 'deco_pins');
if (rewardId === undefined) return undefined; if (rewardId === undefined) return undefined;
const { bangDreamMainDataRepository } = const { bangdreamCatalogRepository } =
await import('../../application/main-data.repository'); await import('../../application/catalog/bangdream-catalog-repository');
const allDeco = const allDeco =
bangDreamMainDataRepository.getCollection<Record<string, any>>('deco'); bangdreamCatalogRepository.getCollection<Record<string, any>>('deco');
const decoAssetName = allDeco[rewardId]?.assetBundleName; const decoAssetName = allDeco[rewardId]?.assetBundleName;
if (!decoAssetName) return undefined; 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'; import type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
export type EventStageDataType = 'stages' | 'rotationMusics'; export type EventStageDataType = 'stages' | 'rotationMusics';
@ -20,7 +20,7 @@ export type EventStageDataRows<T extends EventStageDataType> =
export class EventStageDataRepository { export class EventStageDataRepository {
constructor( constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider,
) {} ) {}
/** /**

View File

@ -1,6 +1,6 @@
import { Canvas, Image } from 'skia-canvas'; import { Canvas, Image } from 'skia-canvas';
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; 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 { Attribute } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/attribute.model';
import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model';
import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config'; import { globalDefaultServer } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
@ -95,7 +95,7 @@ export class Event {
*/ */
constructor(eventId: number) { constructor(eventId: number) {
this.eventId = eventId; this.eventId = eventId;
const eventData = bangDreamMainDataRepository.getEntity< const eventData = bangdreamCatalogRepository.getEntity<
Record<string, any> Record<string, any>
>('events', eventId); >('events', eventId);
if (eventData == undefined) { if (eventData == undefined) {
@ -337,7 +337,7 @@ export function getPresentEvent(server: Server, time?: number) {
time = Date.now(); time = Date.now();
} }
const eventList: Array<number> = []; const eventList: Array<number> = [];
const eventListMain = bangDreamMainDataRepository.getCollection('events'); const eventListMain = bangdreamCatalogRepository.getCollection('events');
for (const key in eventListMain) { for (const key in eventListMain) {
const event = new Event(parseInt(key)); const event = new Event(parseInt(key));
//如果在活动进行时 //如果在活动进行时
@ -435,7 +435,7 @@ export function getRecentEventListByEventAndServer(
count: number, count: number,
sameType: boolean = false, sameType: boolean = false,
) { ) {
const eventIdList = bangDreamMainDataRepository.getNumericIds('events'); const eventIdList = bangdreamCatalogRepository.getNumericIds('events');
const candidates: CutoffRecentEventCandidate[] = eventIdList const candidates: CutoffRecentEventCandidate[] = eventIdList
.map((eventId) => new Event(eventId)) .map((eventId) => new Event(eventId))
.map((candidate) => ({ .map((candidate) => ({

View File

@ -1,22 +1,22 @@
import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { Event } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { import {
bangDreamMainDataRepository, bangdreamCatalogRepository,
type BangDreamMainDataCollection, type BangDreamCatalogCollection,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository';
export class EventRepository { export class EventRepository {
/** /**
* *
*/ */
getSource(): BangDreamMainDataCollection { getSource(): BangDreamCatalogCollection {
return bangDreamMainDataRepository.getCollection('events'); return bangdreamCatalogRepository.getCollection('events');
} }
/** /**
* ID * ID
*/ */
getEventIds(): number[] { 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 type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
import { import {
getServerByPriority, getServerByPriority,
@ -22,7 +22,7 @@ function toServerCode(server: Server | undefined): string {
export class GachaResourceRepository { export class GachaResourceRepository {
constructor( 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 { Image, loadImage } from 'skia-canvas';
import { import {
Server, Server,
@ -79,7 +79,7 @@ export class Gacha {
*/ */
constructor(gachaId: number) { constructor(gachaId: number) {
this.gachaId = gachaId; this.gachaId = gachaId;
const gachaData = bangDreamMainDataRepository.getEntity< const gachaData = bangdreamCatalogRepository.getEntity<
Record<string, any> Record<string, any>
>('gacha', gachaId); >('gacha', gachaId);
if (gachaData == undefined) { if (gachaData == undefined) {
@ -247,7 +247,7 @@ export async function getPresentGachaList(
end: number = Date.now(), end: number = Date.now(),
): Promise<Array<Gacha>> { ): Promise<Array<Gacha>> {
const gachaList: Array<Gacha> = []; const gachaList: Array<Gacha> = [];
const gachaListMain = bangDreamMainDataRepository.getCollection('gacha'); const gachaListMain = bangdreamCatalogRepository.getCollection('gacha');
for (const gachaId in gachaListMain) { for (const gachaId in gachaListMain) {
if (Object.prototype.hasOwnProperty.call(gachaListMain, gachaId)) { 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 { Gacha } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha.model';
import { import {
bangDreamMainDataRepository, bangdreamCatalogRepository,
type BangDreamMainDataCollection, type BangDreamCatalogCollection,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository';
export class GachaRepository { export class GachaRepository {
/** /**
* *
*/ */
getSource(): BangDreamMainDataCollection { getSource(): BangDreamCatalogCollection {
return bangDreamMainDataRepository.getCollection('gacha'); return bangdreamCatalogRepository.getCollection('gacha');
} }
/** /**

View File

@ -1,13 +1,13 @@
import * as path from 'node:path'; 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 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 { assetsRootPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import { readBangDreamAsset } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; import { readBangDreamAsset } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
export class DeckRankResourceRepository { export class DeckRankResourceRepository {
constructor( constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider,
private readonly localRankRootPath: string = path.join( private readonly localRankRootPath: string = path.join(
assetsRootPath, assetsRootPath,
'Rank', '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 { Band } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/band.model';
import { drawTextWithImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-text'; 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 { 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 { import {
BANGDREAM_DECK_TOTAL_RATING_ID, BANGDREAM_DECK_TOTAL_RATING_ID,
BANGDREAM_STAGE_CHALLENGE_BAND_ID, BANGDREAM_STAGE_CHALLENGE_BAND_ID,
@ -81,7 +81,7 @@ export async function drawPlayerBandRankInList(
): Promise<Canvas> { ): Promise<Canvas> {
const bandRankMap = player.profile.bandRankMap?.entries; const bandRankMap = player.profile.bandRankMap?.entries;
const BandDetails = {}; const BandDetails = {};
for (const i in mainAPI['bands']) { for (const i in bangdreamCatalogCache['bands']) {
if (bandRankMap[i] != undefined) { if (bandRankMap[i] != undefined) {
BandDetails[i] = [bandRankMap[i].toString()]; BandDetails[i] = [bandRankMap[i].toString()];
} else { } else {
@ -107,7 +107,7 @@ export async function drawPlayerStageChallengeRankInList(
player.profile.stageChallengeAchievementConditionsMap.entries; player.profile.stageChallengeAchievementConditionsMap.entries;
const BandDetails = {}; const BandDetails = {};
for (const band in mainAPI['bands']) { for (const band in bangdreamCatalogCache['bands']) {
const level = const level =
stageChallengeAchievementConditionsMap?.[ stageChallengeAchievementConditionsMap?.[
BANGDREAM_STAGE_CHALLENGE_BAND_ID[band] BANGDREAM_STAGE_CHALLENGE_BAND_ID[band]
@ -147,7 +147,7 @@ export async function drawPlayerDeckTotalRatingInList(
const userDeckTotalRatingMap = player.profile.userDeckTotalRatingMap.entries; const userDeckTotalRatingMap = player.profile.userDeckTotalRatingMap.entries;
const BandDetails = {}; const BandDetails = {};
for (const i in mainAPI['bands']) { for (const i in bangdreamCatalogCache['bands']) {
if (userDeckTotalRatingMap[i] != undefined) { if (userDeckTotalRatingMap[i] != undefined) {
const rankName = userDeckTotalRatingMap[i].rank; const rankName = userDeckTotalRatingMap[i].rank;
let rankId = BANGDREAM_DECK_TOTAL_RATING_ID[rankName]; 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 { resizeImage } from '@/modules/qqbot/plugins/bangdream/src/theme/image-stack';
import { drawTextWithImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-text'; import { drawTextWithImages } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-text';
import { Character } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character.model'; 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 { import {
createCharacterDetailIconSpec, createCharacterDetailIconSpec,
createCharacterDetailItemLayout, createCharacterDetailItemLayout,
@ -69,7 +69,7 @@ async function drawCharacterInList(
export async function drawCharacterRankInList(player: Player, key?: string) { export async function drawCharacterRankInList(player: Player, key?: string) {
const characterRankMap = player.profile.userCharacterRankMap?.entries; const characterRankMap = player.profile.userCharacterRankMap?.entries;
const CharacterDetailsInListOptions = {}; const CharacterDetailsInListOptions = {};
for (const i in mainAPI['characters']) { for (const i in bangdreamCatalogCache['characters']) {
if (characterRankMap[i] != undefined) { if (characterRankMap[i] != undefined) {
CharacterDetailsInListOptions[i] = [`${characterRankMap[i].rank}`]; CharacterDetailsInListOptions[i] = [`${characterRankMap[i].rank}`];
} else { } 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 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 { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
@ -16,7 +16,7 @@ export interface PlayerDetailResponse {
export class PlayerDataRepository { export class PlayerDataRepository {
constructor( 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 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'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
export class PlayerRankingResourceRepository { export class PlayerRankingResourceRepository {
constructor( 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 { 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 { BangDreamServerId as Server } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol';
import { import {
@ -42,7 +42,7 @@ export interface CnEventEstimateCalculationContext {
export function getBangDreamEventSchedule( export function getBangDreamEventSchedule(
eventId: number, eventId: number,
): BangDreamEventSchedule | null { ): BangDreamEventSchedule | null {
const eventData = bangDreamMainDataRepository.getEntity<Record<string, any>>( const eventData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'events', 'events',
eventId, eventId,
); );
@ -64,7 +64,7 @@ export function getPresentBangDreamEventId(
server: number, server: number,
time = Date.now(), time = Date.now(),
): number | null { ): number | null {
const eventIds = bangDreamMainDataRepository.getNumericIds('events'); const eventIds = bangdreamCatalogRepository.getNumericIds('events');
const activeEventIds: number[] = []; const activeEventIds: number[] = [];
let latestEndedEventId: number | null = null; let latestEndedEventId: number | null = null;
let latestEndedAt = 0; let latestEndedAt = 0;

View File

@ -23,7 +23,7 @@ export interface FuzzySearchRule {
match: (keyword: FuzzySearchKeyword, push: FuzzySearchResultWriter) => void; match: (keyword: FuzzySearchKeyword, push: FuzzySearchResultWriter) => void;
} }
export class FuzzySearchRuleRegistry { export class FuzzySearchRules {
constructor(private readonly rules: readonly FuzzySearchRule[]) {} constructor(private readonly rules: readonly FuzzySearchRule[]) {}
/** /**
@ -61,10 +61,10 @@ export function createFuzzySearchKeyword(
* *
* @param config - * @param config -
*/ */
export function createDefaultFuzzySearchRuleRegistry( export function createDefaultFuzzySearchRules(
config: FuzzySearchConfig, config: FuzzySearchConfig,
) { ) {
return new FuzzySearchRuleRegistry([ return new FuzzySearchRules([
createNumberRule(), createNumberRule(),
createLevelRule(), createLevelRule(),
createRelationRule(), createRelationRule(),

View File

@ -1,7 +1,7 @@
import { import {
createDefaultFuzzySearchRuleRegistry, createDefaultFuzzySearchRules,
createFuzzySearchKeyword, 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 { searchDictionaryRepository } from '@/modules/qqbot/plugins/bangdream/src/domain/search/search-dictionary.repository';
import type { import type {
FuzzySearchConfig, FuzzySearchConfig,
@ -23,8 +23,8 @@ const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g;
const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']);
let cachedConfig: FuzzySearchConfig | undefined; let cachedConfig: FuzzySearchConfig | undefined;
let cachedRuleRegistry: let cachedRules:
| ReturnType<typeof createDefaultFuzzySearchRuleRegistry> | ReturnType<typeof createDefaultFuzzySearchRules>
| undefined; | undefined;
export const config = new Proxy({} as FuzzySearchConfig, { export const config = new Proxy({} as FuzzySearchConfig, {
@ -38,11 +38,11 @@ function getFuzzySearchConfig() {
return cachedConfig; return cachedConfig;
} }
function getRuleRegistry() { function getFuzzySearchRules() {
cachedRuleRegistry ??= createDefaultFuzzySearchRuleRegistry( cachedRules ??= createDefaultFuzzySearchRules(
getFuzzySearchConfig(), getFuzzySearchConfig(),
); );
return cachedRuleRegistry; return cachedRules;
} }
/** /**
@ -105,7 +105,7 @@ export function fuzzySearch(keyword: string): FuzzySearchResult {
const push = appendTo(matches); const push = appendTo(matches);
for (const rawKeyword of extractKeywords(keyword)) { for (const rawKeyword of extractKeywords(keyword)) {
getRuleRegistry().match(createFuzzySearchKeyword(rawKeyword), push); getFuzzySearchRules().match(createFuzzySearchKeyword(rawKeyword), push);
} }
return matches; return matches;

View File

@ -1,5 +1,5 @@
import { assetErrorImageBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; 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 type { BangDreamDataProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bangdream-data-provider';
import { import {
BANGDREAM_DIFFICULTY_NAME_BY_ID, BANGDREAM_DIFFICULTY_NAME_BY_ID,
@ -31,7 +31,7 @@ function getServerCode(server: Server | undefined): string {
export class SongResourceRepository { export class SongResourceRepository {
constructor( constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, private readonly provider: BangDreamDataProvider = bangdreamBestdoriProvider,
) {} ) {}
/** /**

View File

@ -1,6 +1,6 @@
import { Image, loadImage } from 'skia-canvas'; import { Image, loadImage } from 'skia-canvas';
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; 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 { stringToNumberArray } from '@/modules/qqbot/plugins/bangdream/src/domain/common/model-utils';
import { import {
BANGDREAM_DIFFICULTY_COLORS, BANGDREAM_DIFFICULTY_COLORS,
@ -99,7 +99,7 @@ export class Song {
*/ */
constructor(songId: number) { constructor(songId: number) {
this.songId = songId; this.songId = songId;
const songData = bangDreamMainDataRepository.getEntity<Record<string, any>>( const songData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'songs', 'songs',
songId, songId,
); );
@ -131,7 +131,7 @@ export class Song {
} }
//meta数据 //meta数据
const metaData = bangDreamMainDataRepository.getEntity<Record<string, any>>( const metaData = bangdreamCatalogRepository.getEntity<Record<string, any>>(
'meta', 'meta',
songId, songId,
); );
@ -321,7 +321,7 @@ export function getPresentSongList(
end: number = Date.now(), end: number = Date.now(),
): Song[] { ): Song[] {
const songList: Array<Song> = []; const songList: Array<Song> = [];
const songListMain = bangDreamMainDataRepository.getCollection('songs'); const songListMain = bangdreamCatalogRepository.getCollection('songs');
for (const songId in songListMain) { for (const songId in songListMain) {
if (Object.prototype.hasOwnProperty.call(songListMain, songId)) { if (Object.prototype.hasOwnProperty.call(songListMain, songId)) {
@ -368,7 +368,7 @@ export function getMetaRanking(
withFever: boolean, withFever: boolean,
mainServer: Server, mainServer: Server,
): SongInRank[] { ): SongInRank[] {
const songIdList = bangDreamMainDataRepository.getNumericIds('meta'); const songIdList = bangdreamCatalogRepository.getNumericIds('meta');
const songRankList: SongInRank[] = []; const songRankList: SongInRank[] = [];
for (let i = 0; i < songIdList.length; i++) { for (let i = 0; i < songIdList.length; i++) {
const songId = songIdList[i]; const songId = songIdList[i];

View File

@ -1,15 +1,15 @@
import { Song } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'; import { Song } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model';
import { import {
bangDreamMainDataRepository, bangdreamCatalogRepository,
type BangDreamMainDataCollection, type BangDreamCatalogCollection,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data.repository'; } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-repository';
export class SongRepository { export class SongRepository {
/** /**
* *
*/ */
getSource(): BangDreamMainDataCollection { getSource(): BangDreamCatalogCollection {
return bangDreamMainDataRepository.getCollection('songs'); return bangdreamCatalogRepository.getCollection('songs');
} }
/** /**

View File

@ -3,10 +3,10 @@ import {
type BangDreamCommandContextOptions, type BangDreamCommandContextOptions,
} from './application/bangdream-command-context'; } from './application/bangdream-command-context';
import { import {
createBangDreamHookContext, createBangDreamOperationLifecycleContext,
createBangDreamLogHook, createBangDreamOperationLogObserver,
BangDreamHookRegistry, BangDreamOperationLifecycle,
} from './application/hook/hook-registry'; } from './application/execution/operation-lifecycle';
import { import {
configureBangDreamRuntimeIo, configureBangDreamRuntimeIo,
type BangDreamRuntimeIo, type BangDreamRuntimeIo,
@ -16,14 +16,14 @@ import {
type BangDreamOperationModule, type BangDreamOperationModule,
} from './operations'; } from './operations';
import type { import type {
QqbotBangDreamCommandInput, BangDreamCommandInput,
QqbotBangDreamCommandOutput, BangDreamCommandOutput,
QqbotBangDreamOperationHandlerName, BangDreamOperationHandlerName,
QqbotBangDreamOperationKey, BangDreamOperationKey,
} from './domain/common/qqbot-bangdream.types'; } from './domain/common/bangdream.types';
import { waitForMainDataReady } from './application/main-data-store'; import { waitForBangDreamCatalogReady } from './application/catalog/bangdream-catalog-cache';
export type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & { type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & {
description?: string; description?: string;
io?: BangDreamRuntimeIo; io?: BangDreamRuntimeIo;
legacyAliases?: string[]; legacyAliases?: string[];
@ -34,13 +34,15 @@ export type BangDreamPluginRuntimeOptions = BangDreamCommandContextOptions & {
version?: string; version?: string;
}; };
export type BangDreamManifestOperation = { type BangDreamManifestOperation = {
aliases?: string[];
description?: string; description?: string;
handlerName: QqbotBangDreamOperationHandlerName; handlerName: BangDreamOperationHandlerName;
inputSchema?: Record<string, any>; inputSchema?: Record<string, any>;
key: QqbotBangDreamOperationKey; key: BangDreamOperationKey;
name?: string; name?: string;
outputSchema?: Record<string, any>; outputSchema?: Record<string, any>;
timeoutMs?: number;
}; };
type BangDreamResolvedOperation = BangDreamManifestOperation & { type BangDreamResolvedOperation = BangDreamManifestOperation & {
@ -50,7 +52,9 @@ type BangDreamResolvedOperation = BangDreamManifestOperation & {
export function createPlugin(options: BangDreamPluginRuntimeOptions) { export function createPlugin(options: BangDreamPluginRuntimeOptions) {
if (options.io) configureBangDreamRuntimeIo(options.io); if (options.io) configureBangDreamRuntimeIo(options.io);
const context = new BangDreamCommandContext(options); const context = new BangDreamCommandContext(options);
const hookRegistry = new BangDreamHookRegistry([createBangDreamLogHook()]); const lifecycle = new BangDreamOperationLifecycle([
createBangDreamOperationLogObserver(),
]);
const operationsByKey = resolveBangDreamOperations(options.operations); const operationsByKey = resolveBangDreamOperations(options.operations);
const normalizeError = const normalizeError =
options.normalizeError || options.normalizeError ||
@ -59,12 +63,12 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
'BangDream 命令执行失败'); 'BangDream 命令执行失败');
const executeOperation = ( const executeOperation = (
operationKey: QqbotBangDreamOperationKey, operationKey: BangDreamOperationKey,
input: QqbotBangDreamCommandInput, input: BangDreamCommandInput,
) => ) =>
executeBangDreamOperation({ executeBangDreamOperation({
context, context,
hookRegistry, lifecycle,
input, input,
normalizeError, normalizeError,
operationKey, operationKey,
@ -98,13 +102,15 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
legacyKeys: options.legacyAliases, legacyKeys: options.legacyAliases,
name: options.name || 'BangDream 查询', name: options.name || 'BangDream 查询',
operations: options.operations.map((operation) => ({ operations: options.operations.map((operation) => ({
aliases: operation.aliases,
cacheTtlMs: 60_000, cacheTtlMs: 60_000,
description: operation.description, description: operation.description,
inputSchema: operation.inputSchema || getBangDreamInputSchema(), inputSchema: operation.inputSchema || getBangDreamInputSchema(),
key: operation.key, key: operation.key,
name: operation.name || operation.key, name: operation.name || operation.key,
outputSchema: operation.outputSchema || getBangDreamOutputSchema(), outputSchema: operation.outputSchema || getBangDreamOutputSchema(),
execute: async (input: QqbotBangDreamCommandInput) => timeoutMs: operation.timeoutMs,
execute: async (input: BangDreamCommandInput) =>
await executeOperation(operation.key, input), await executeOperation(operation.key, input),
})), })),
version: options.version || '2.0.0', version: options.version || '2.0.0',
@ -134,21 +140,21 @@ function resolveBangDreamOperations(operations: BangDreamManifestOperation[]) {
async function executeBangDreamOperation(options: { async function executeBangDreamOperation(options: {
context: BangDreamCommandContext; context: BangDreamCommandContext;
hookRegistry: BangDreamHookRegistry; lifecycle: BangDreamOperationLifecycle;
input: QqbotBangDreamCommandInput; input: BangDreamCommandInput;
normalizeError: (error: unknown) => string; normalizeError: (error: unknown) => string;
operationKey: QqbotBangDreamOperationKey; operationKey: BangDreamOperationKey;
operationsByKey: Map<QqbotBangDreamOperationKey, BangDreamResolvedOperation>; operationsByKey: Map<BangDreamOperationKey, BangDreamResolvedOperation>;
}): Promise<QqbotBangDreamCommandOutput> { }): Promise<BangDreamCommandOutput> {
const operationContext = createBangDreamHookContext( const operationContext = createBangDreamOperationLifecycleContext(
options.operationKey, options.operationKey,
options.input, options.input,
); );
await options.hookRegistry.beforeParse(operationContext); await options.lifecycle.beforeParse(operationContext);
try { try {
operationContext.stage = 'mainData'; operationContext.stage = 'catalog';
await waitForMainDataReady(); await waitForBangDreamCatalogReady();
operationContext.stage = 'operation'; operationContext.stage = 'operation';
const operation = options.operationsByKey.get(options.operationKey); const operation = options.operationsByKey.get(options.operationKey);
@ -156,31 +162,24 @@ async function executeBangDreamOperation(options: {
throw new Error(`BangDream 插件能力不存在:${options.operationKey}`); throw new Error(`BangDream 插件能力不存在:${options.operationKey}`);
} }
operationContext.handlerName = operation.handlerName; operationContext.handlerName = operation.handlerName;
await options.hookRegistry.afterResolve(operationContext); await options.lifecycle.afterResolve(operationContext);
operationContext.stage = 'handler'; operationContext.stage = 'handler';
await options.hookRegistry.beforeRender(operationContext); await options.lifecycle.beforeRender(operationContext);
const output = await operation.execute(options.input, options.context); const output = await operation.execute(options.input, options.context);
operationContext.stage = 'output'; operationContext.stage = 'output';
operationContext.imageCount = output.imageCount; operationContext.imageCount = output.imageCount;
operationContext.query = output.query || operationContext.query; operationContext.query = output.query || operationContext.query;
await options.hookRegistry.afterOutput(operationContext); await options.lifecycle.afterOutput(operationContext);
return output; return output;
} catch (error) { } catch (error) {
const message = options.normalizeError(error); const message = options.normalizeError(error);
await options.hookRegistry.onError(operationContext, message); await options.lifecycle.onError(operationContext, message);
throw new Error(message); throw new Error(message);
} }
} }
export type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationHandlerName,
QqbotBangDreamOperationKey,
} from './domain/common/qqbot-bangdream.types';
function getBangDreamInputSchema() { function getBangDreamInputSchema() {
return { return {
properties: { 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 { import {
getCacheDirectory, getCacheDirectory,
getFileNameFromUrl, getFileNameFromUrl,
@ -32,7 +32,8 @@ async function callAPIAndCacheResponse(
const cacheDir = getCacheDirectory(url); const cacheDir = getCacheDirectory(url);
const fileName = getFileNameFromUrl(url); const fileName = getFileNameFromUrl(url);
return await runWithCacheClientRetry({ return await runWithCacheClientRetry({
action: () => getJsonAndSave(url, cacheDir, fileName, cacheTime), action: () =>
fetchRemoteResourceJson(url, cacheDir, fileName, cacheTime),
onFailure: (attempt, _retryCount, error) => { onFailure: (attempt, _retryCount, error) => {
if (isCacheClientNotFound(error)) { if (isCacheClientNotFound(error)) {
logger( 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)); 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, getCacheDirectory,
getFileNameFromUrl, getFileNameFromUrl,
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/cache-path'; } 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 { Buffer } from 'buffer';
import { assetErrorImageBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image'; import { assetErrorImageBuffer } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-image';
import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger'; import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
@ -53,7 +53,12 @@ async function downloadFile(
return await runWithCacheClientRetry({ return await runWithCacheClientRetry({
action: async () => { action: async () => {
assetNotExists = false; 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 htmlSig = Buffer.from('<!DOCTYPE html>');
const slice = Buffer.from(data.subarray(0, htmlSig.length)); const slice = Buffer.from(data.subarray(0, htmlSig.length));
if (slice.equals(htmlSig)) { if (slice.equals(htmlSig)) {

View File

@ -36,7 +36,7 @@ function rememberNotFound(url: string, statusCode?: number) {
if (statusCode === 404) errorUrlCache[url] = Date.now(); if (statusCode === 404) errorUrlCache[url] = Date.now();
} }
export async function download( export async function fetchRemoteResourceBuffer(
url: string, url: string,
_directory?: string, _directory?: string,
_fileName?: 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, url: string,
_directory?: string, _directory?: string,
_fileName?: 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 { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; 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 = { export const cutoffDetailOperation: BangDreamOperationModule = {
handlerName: 'getCutoffDetail', handlerName: 'getCutoffDetail',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);
const tier = context.requireNumber(input.tier, tokens[0], '请提供档位'); const hasCommandAlias = CUTOFF_DETAIL_COMMAND_ALIASES.has(tokens[0]);
const mainServer = context.pickMainServer(input, tokens.slice(1)); 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 = const eventId =
context.optionalNumber(input.eventId) ?? context.optionalNumber(input.eventId) ??
context.firstNumber(tokens.slice(1)) ?? context.firstNumber(remainingTokens) ??
getPresentEvent(mainServer).eventId; getPresentEvent(mainServer).eventId;
const options = context.getRenderOptions({ ...input, mainServer }); const options = context.getRenderOptions({ ...input, mainServer });
const images = const images =

View File

@ -1,17 +1,17 @@
import type { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context'; import type { BangDreamCommandContext } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-command-context';
import type { import type {
QqbotBangDreamCommandInput, BangDreamCommandInput,
QqbotBangDreamCommandOutput, BangDreamCommandOutput,
QqbotBangDreamOperationHandlerName, BangDreamOperationHandlerName,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types'; } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types';
export type BangDreamOperationExecute = ( export type BangDreamOperationExecute = (
input: QqbotBangDreamCommandInput, input: BangDreamCommandInput,
context: BangDreamCommandContext, context: BangDreamCommandContext,
) => Promise<QqbotBangDreamCommandOutput>; ) => Promise<BangDreamCommandOutput>;
export type BangDreamOperationModule = { export type BangDreamOperationModule = {
execute: BangDreamOperationExecute; execute: BangDreamOperationExecute;
expectedImageCount?: number; expectedImageCount?: number;
handlerName: QqbotBangDreamOperationHandlerName; handlerName: BangDreamOperationHandlerName;
}; };

View File

@ -1,7 +1,7 @@
import { Canvas, loadImage, Image } from 'skia-canvas'; import { Canvas, loadImage, Image } from 'skia-canvas';
import * as svg2img from 'svg2img'; import * as svg2img from 'svg2img';
import { import {
bangDreamFallbackImageBuffer, bangdreamFallbackImageBuffer,
readBangDreamAsset, readBangDreamAsset,
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; } 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, callback: (error: Error | null, buffer: Buffer) => void,
) => void; ) => void;
export const assetErrorImageBuffer = bangDreamFallbackImageBuffer; export const assetErrorImageBuffer = bangdreamFallbackImageBuffer;
/** /**
* FromPath * FromPath

View File

@ -1,4 +1,5 @@
import { Ff14MarketClient } from '../infrastructure/integration/ff14-market-client'; import { Ff14MarketClient } from '../infrastructure/integration/ff14-market-client';
import { parseFf14MarketPriceInput } from './ff14-market-input-parser';
export class Ff14MarketApplication { export class Ff14MarketApplication {
constructor(private readonly client: Ff14MarketClient) {} constructor(private readonly client: Ff14MarketClient) {}
@ -7,6 +8,13 @@ export class Ff14MarketApplication {
return this.client.getPrice(input); return this.client.getPrice(input);
} }
async parsePriceInput(rawArgs: string) {
return parseFf14MarketPriceInput(
rawArgs,
await this.client.getMarketCatalog(),
);
}
async resolveItem(input: Record<string, any>) { async resolveItem(input: Record<string, any>) {
return this.client.resolveItem(input); 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 { import {
isQqbotFf14DataCenterName, isFf14DataCenterName,
isQqbotFf14LocationName, isFf14LocationName,
isQqbotFf14RegionName, isFf14RegionName,
isQqbotFf14WorldName, isFf14WorldName,
splitQqbotFf14WorldPath, splitFf14WorldPath,
} from '../domain/ff14-worlds'; } from '../domain/ff14-worlds';
export type QqbotFf14MarketPriceInput = { export type Ff14MarketPriceInput = {
dataCenter?: string; dataCenter?: string;
hq?: boolean; hq?: boolean;
item?: string; item?: string;
@ -17,10 +17,10 @@ export type QqbotFf14MarketPriceInput = {
world?: string; world?: string;
}; };
export function parseQqbotFf14MarketPriceInput( export function parseFf14MarketPriceInput(
rawArgs: string, rawArgs: string,
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
): QqbotFf14MarketPriceInput { ): Ff14MarketPriceInput {
const tokens = rawArgs.split(/\s+/).filter(Boolean); const tokens = rawArgs.split(/\s+/).filter(Boolean);
const flags = new Map<string, string | true>(); const flags = new Map<string, string | true>();
const positional: string[] = []; const positional: string[] = [];
@ -53,7 +53,7 @@ export function parseQqbotFf14MarketPriceInput(
); );
let item = positional.join(' '); let item = positional.join(' ');
const worldPath = splitQqbotFf14WorldPath(world); const worldPath = splitFf14WorldPath(world);
if (worldPath.dataCenter && worldPath.world) { if (worldPath.dataCenter && worldPath.world) {
dataCenter = dataCenter || worldPath.dataCenter; dataCenter = dataCenter || worldPath.dataCenter;
region = region || worldPath.region || ''; region = region || worldPath.region || '';
@ -71,7 +71,7 @@ export function parseQqbotFf14MarketPriceInput(
} }
if (item.includes('@')) { if (item.includes('@')) {
const [itemName, worldName] = item.split('@'); const [itemName, worldName] = item.split('@');
const itemWorldPath = splitQqbotFf14WorldPath(worldName); const itemWorldPath = splitFf14WorldPath(worldName);
item = itemName.trim(); item = itemName.trim();
dataCenter = dataCenter || itemWorldPath.dataCenter || ''; dataCenter = dataCenter || itemWorldPath.dataCenter || '';
region = region || itemWorldPath.region || ''; region = region || itemWorldPath.region || '';
@ -90,13 +90,13 @@ export function parseQqbotFf14MarketPriceInput(
} }
function pickTrailingFf14Location( function pickTrailingFf14Location(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
positional: string[], positional: string[],
) { ) {
const last = positional[positional.length - 1]; 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) { if (path.dataCenter && path.world) {
return { return {
dataCenter: path.dataCenter, dataCenter: path.dataCenter,
@ -110,11 +110,11 @@ function pickTrailingFf14Location(
const beforePrevious = positional[positional.length - 3]; const beforePrevious = positional[positional.length - 3];
if ( if (
previous && previous &&
isQqbotFf14DataCenterName(catalog, previous) && isFf14DataCenterName(catalog, previous) &&
isQqbotFf14WorldName(catalog, last) isFf14WorldName(catalog, last)
) { ) {
const hasRegion = const hasRegion =
beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious); beforePrevious && isFf14RegionName(catalog, beforePrevious);
return { return {
dataCenter: previous, dataCenter: previous,
item: positional.slice(0, hasRegion ? -3 : -2).join(' '), item: positional.slice(0, hasRegion ? -3 : -2).join(' '),
@ -125,8 +125,8 @@ function pickTrailingFf14Location(
if ( if (
previous && previous &&
isQqbotFf14RegionName(catalog, previous) && isFf14RegionName(catalog, previous) &&
isQqbotFf14DataCenterName(catalog, last) isFf14DataCenterName(catalog, last)
) { ) {
return { return {
dataCenter: last, dataCenter: last,

View File

@ -1,18 +1,18 @@
export type Ff14HttpMethod = 'GET'; export type Ff14HttpMethod = 'GET';
export type QqbotFf14DataCenter = { export type Ff14DataCenter = {
name: string; name: string;
region: string; region: string;
worlds: string[]; worlds: string[];
}; };
export type QqbotFf14MarketCatalog = { export type Ff14MarketCatalog = {
dataCenters: QqbotFf14DataCenter[]; dataCenters: Ff14DataCenter[];
defaultRegion?: string; defaultRegion?: string;
regions: string[]; regions: string[];
}; };
export type QqbotFf14MarketTarget = { export type Ff14MarketTarget = {
dataCenter?: string; dataCenter?: string;
label: string; label: string;
region?: string; region?: string;
@ -20,7 +20,7 @@ export type QqbotFf14MarketTarget = {
world?: string; world?: string;
}; };
export type QqbotFf14ResolvedItem = { export type Ff14ResolvedItem = {
icon?: string; icon?: string;
isUntradable?: boolean; isUntradable?: boolean;
itemId: number; itemId: number;
@ -38,10 +38,10 @@ export type UniversalisListing = {
worldName?: string; worldName?: string;
}; };
export type QqbotFf14PriceResult = { export type Ff14PriceResult = {
averagePrice?: number; averagePrice?: number;
hq?: boolean; hq?: boolean;
item: QqbotFf14ResolvedItem; item: Ff14ResolvedItem;
listings: UniversalisListing[]; listings: UniversalisListing[];
minPrice?: number; minPrice?: number;
replyText: string; replyText: string;

View File

@ -1,7 +1,7 @@
import type { import type {
QqbotFf14DataCenter, Ff14DataCenter,
QqbotFf14MarketCatalog, Ff14MarketCatalog,
QqbotFf14MarketTarget, Ff14MarketTarget,
} from './ff14-market.types'; } from './ff14-market.types';
export type Ff14DictItem = { export type Ff14DictItem = {
@ -22,11 +22,11 @@ export const QQBOT_FF14_MARKET_DICT_CODES = {
world: 'FF14_MARKET_WORLD', world: 'FF14_MARKET_WORLD',
}; };
export function buildQqbotFf14MarketCatalog(input: { export function buildFf14MarketCatalog(input: {
dataCenters: Ff14DictItem[]; dataCenters: Ff14DictItem[];
regions: Ff14DictItem[]; regions: Ff14DictItem[];
worlds: Ff14DictItem[]; worlds: Ff14DictItem[];
}): QqbotFf14MarketCatalog { }): Ff14MarketCatalog {
const regions = input.regions.map(getDictDisplayValue).filter(Boolean); const regions = input.regions.map(getDictDisplayValue).filter(Boolean);
const defaultRegion = regions[0]; const defaultRegion = regions[0];
const dataCenters = input.dataCenters const dataCenters = input.dataCenters
@ -36,14 +36,14 @@ export function buildQqbotFf14MarketCatalog(input: {
return { return {
name, name,
region: region:
normalizeQqbotFf14WorldValue(item.childrenCode) || defaultRegion, normalizeFf14WorldValue(item.childrenCode) || defaultRegion,
worlds: input.worlds worlds: input.worlds
.filter(({ childrenCode }) => childrenCode === getDictRawValue(item)) .filter(({ childrenCode }) => childrenCode === getDictRawValue(item))
.map(getDictDisplayValue) .map(getDictDisplayValue)
.filter(Boolean), .filter(Boolean),
}; };
}) })
.filter((item): item is QqbotFf14DataCenter => !!item); .filter((item): item is Ff14DataCenter => !!item);
return { return {
dataCenters, dataCenters,
@ -52,9 +52,9 @@ export function buildQqbotFf14MarketCatalog(input: {
}; };
} }
export function buildQqbotFf14MarketCatalogFromTree( export function buildFf14MarketCatalogFromTree(
roots: Ff14DictItem[], roots: Ff14DictItem[],
): QqbotFf14MarketCatalog { ): Ff14MarketCatalog {
const regionNodes = roots.filter( const regionNodes = roots.filter(
(item) => item.dictCode === QQBOT_FF14_MARKET_DICT_CODES.region, (item) => item.dictCode === QQBOT_FF14_MARKET_DICT_CODES.region,
); );
@ -76,7 +76,7 @@ export function buildQqbotFf14MarketCatalogFromTree(
.filter(Boolean), .filter(Boolean),
}; };
}) })
.filter((item): item is QqbotFf14DataCenter => !!item); .filter((item): item is Ff14DataCenter => !!item);
}); });
return { return {
@ -86,46 +86,46 @@ export function buildQqbotFf14MarketCatalogFromTree(
}; };
} }
export function isQqbotFf14DataCenterName( export function isFf14DataCenterName(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
value?: string, value?: string,
) { ) {
const name = normalizeQqbotFf14WorldValue(value); const name = normalizeFf14WorldValue(value);
return catalog.dataCenters.some((item) => item.name === name); return catalog.dataCenters.some((item) => item.name === name);
} }
export function isQqbotFf14RegionName( export function isFf14RegionName(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
value?: string, value?: string,
) { ) {
const name = normalizeQqbotFf14WorldValue(value); const name = normalizeFf14WorldValue(value);
return catalog.regions.includes(name); return catalog.regions.includes(name);
} }
export function isQqbotFf14WorldName( export function isFf14WorldName(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
value?: string, value?: string,
) { ) {
const name = normalizeQqbotFf14WorldValue(value); const name = normalizeFf14WorldValue(value);
return catalog.dataCenters.some((item) => item.worlds.includes(name)); return catalog.dataCenters.some((item) => item.worlds.includes(name));
} }
export function isQqbotFf14LocationName( export function isFf14LocationName(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
value?: string, value?: string,
) { ) {
const name = normalizeQqbotFf14WorldValue(value); const name = normalizeFf14WorldValue(value);
const path = splitQqbotFf14WorldPath(name); const path = splitFf14WorldPath(name);
return ( return (
isQqbotFf14RegionName(catalog, name) || isFf14RegionName(catalog, name) ||
isQqbotFf14DataCenterName(catalog, name) || isFf14DataCenterName(catalog, name) ||
isQqbotFf14WorldName(catalog, name) || isFf14WorldName(catalog, name) ||
(!!path.dataCenter && !!path.world) (!!path.dataCenter && !!path.world)
); );
} }
export function splitQqbotFf14WorldPath(value?: string) { export function splitFf14WorldPath(value?: string) {
const raw = normalizeQqbotFf14WorldValue(value); const raw = normalizeFf14WorldValue(value);
if (!raw) return {}; if (!raw) return {};
const parts = raw const parts = raw
@ -148,31 +148,31 @@ export function splitQqbotFf14WorldPath(value?: string) {
}; };
} }
export function findQqbotFf14DataCenterByWorld( export function findFf14DataCenterByWorld(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
world?: string, world?: string,
) { ) {
const worldName = normalizeQqbotFf14WorldValue(world); const worldName = normalizeFf14WorldValue(world);
return catalog.dataCenters.find((item) => item.worlds.includes(worldName)); return catalog.dataCenters.find((item) => item.worlds.includes(worldName));
} }
export function resolveQqbotFf14MarketTarget( export function resolveFf14MarketTarget(
catalog: QqbotFf14MarketCatalog, catalog: Ff14MarketCatalog,
params: { params: {
dataCenter?: string; dataCenter?: string;
fallback?: string; fallback?: string;
region?: string; region?: string;
world?: string; world?: string;
}, },
): QqbotFf14MarketTarget { ): Ff14MarketTarget {
const defaultRegion = catalog.defaultRegion || ''; const defaultRegion = catalog.defaultRegion || '';
const fallback = normalizeQqbotFf14WorldValue(params.fallback); const fallback = normalizeFf14WorldValue(params.fallback);
const path = splitQqbotFf14WorldPath(params.world); const path = splitFf14WorldPath(params.world);
const region = normalizeQqbotFf14WorldValue(params.region || path.region); const region = normalizeFf14WorldValue(params.region || path.region);
const dataCenter = normalizeQqbotFf14WorldValue( const dataCenter = normalizeFf14WorldValue(
params.dataCenter || path.dataCenter, 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 world = dataCenter && rawWorld === defaultRegion ? '' : rawWorld;
const raw = world || dataCenter || region || fallback || defaultRegion; 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) { if (matchedWorldDataCenter) {
return { return {
dataCenter: matchedWorldDataCenter.name, dataCenter: matchedWorldDataCenter.name,
@ -222,7 +222,7 @@ export function resolveQqbotFf14MarketTarget(
}; };
} }
if (isQqbotFf14DataCenterName(catalog, raw)) { if (isFf14DataCenterName(catalog, raw)) {
return { return {
dataCenter: raw, dataCenter: raw,
label: defaultRegion ? `${defaultRegion} / ${raw}` : raw, label: defaultRegion ? `${defaultRegion} / ${raw}` : raw,
@ -238,13 +238,13 @@ export function resolveQqbotFf14MarketTarget(
} }
function getDictDisplayValue(item: Ff14DictItem) { function getDictDisplayValue(item: Ff14DictItem) {
return normalizeQqbotFf14WorldValue(item.label || item.value); return normalizeFf14WorldValue(item.label || item.value);
} }
function getDictRawValue(item: Ff14DictItem) { 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(); return `${value || ''}`.trim();
} }

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