From 1c865319bfdd8659436345b334941707d751c316 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 14:30:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E6=94=BEQQBot=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 33 + README.md | 4 +- .../qqbot-account-message-push.controller.ts | 196 ++++ ...qqbot-message-push-permission.decorator.ts | 11 + .../qqbot-message-push-permission.guard.ts | 63 ++ .../qqbot-message-push.controller.ts | 383 ++++++++ .../message-push/qqbot-message-push.dto.ts | 201 +++++ src/modules/qqbot/core/qqbot-core.module.ts | 6 + src/modules/qqbot/core/schema/README.md | 46 + .../core/qqbot-core-module-contract.spec.ts | 67 ++ ...qbot-message-push-permission.guard.spec.ts | 273 ++++++ .../qqbot-message-push.controller.spec.ts | 851 ++++++++++++++++++ 12 files changed, 2133 insertions(+), 1 deletion(-) create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator.ts create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard.ts create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts create mode 100644 src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts create mode 100644 test/modules/qqbot/message-push/qqbot-message-push-permission.guard.spec.ts create mode 100644 test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts diff --git a/API.md b/API.md index e24b8a4..d21ed6b 100644 --- a/API.md +++ b/API.md @@ -415,6 +415,39 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id` 挂载、`SYS_ADMIN`、`apparmor=unconfined`、`seccomp=unconfined` 和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1`;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。派生镜像 entrypoint 会用同一设备 profile 覆盖 QQCore 实际打开的 DMI、boot_id、kernel release/version/proc version、CPU model、uptime、TTY active、mountinfo、`/etc/hosts` 和 `/proc/devices` 等探针;NapCat fork native login 和 core session config 的 `machineId` 与 `systemVersion` 也从该 profile 读取,避免 QQ native 入参和 Docker 可见探针不一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 +### System Message Push + +所有接口先通过 Admin JWT,再按路由声明的权限码执行 OR 校验;只有启用且未删除的角色/菜单生效,启用且未删除的 `super` 可放行。下表中 `/` 分隔的权限表示任一项即可,写接口始终只接受该动作自己的权限。 + +| 方法 | 路径 | 权限 | +| -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/qqbot/message-push/sources` | Subscription `List/Create/Update`;Template `List/Create/Update/Preview`;Account MessagePush `List/Create/Update` | +| `GET` | `/qqbot/message-push/sources/:sourceKey` | 同 source list | +| `GET` | `/qqbot/message-push/sources/network.stun.mapping-port-changed/options` | Subscription `Create/Update`;Account MessagePush `Create/Update` | +| `GET` | `/qqbot/message-push/subscriptions` | Subscription `List`;Account MessagePush `List/Create/Update` | +| `POST` | `/qqbot/message-push/subscriptions` | Subscription `Create` | +| `PUT` | `/qqbot/message-push/subscriptions/:id` | Subscription `Update` | +| `PUT` | `/qqbot/message-push/subscriptions/:id/enabled` | Subscription `Toggle` | +| `DELETE` | `/qqbot/message-push/subscriptions/:id` | Subscription `Delete` | +| `GET` | `/qqbot/message-push/templates` | Template `List`;Account MessagePush `List/Create/Update` | +| `POST` | `/qqbot/message-push/templates` | Template `Create` | +| `PUT` | `/qqbot/message-push/templates/:id` | Template `Update` | +| `PUT` | `/qqbot/message-push/templates/:id/enabled` | Template `Toggle` | +| `DELETE` | `/qqbot/message-push/templates/:id` | Template `Delete` | +| `POST` | `/qqbot/message-push/templates/preview` | Template `Preview` | +| `GET` | `/qqbot/accounts/:selfId/message-push/bindings` | Account MessagePush `List` | +| `POST` | `/qqbot/accounts/:selfId/message-push/bindings` | Account MessagePush `Create` | +| `PUT` | `/qqbot/accounts/:selfId/message-push/bindings/:id` | Account MessagePush `Update` | +| `PUT` | `/qqbot/accounts/:selfId/message-push/bindings/:id/enabled` | Account MessagePush `Toggle` | +| `DELETE` | `/qqbot/accounts/:selfId/message-push/bindings/:id` | Account MessagePush `Delete` | +| `GET` | `/qqbot/accounts/:selfId/message-push/targets` | Account MessagePush `Create/Update` | + +完整权限码前缀分别为 `QqBot:MessageSubscription:*`、`QqBot:MessageTemplate:*` 和 `QqBot:Account:MessagePush:*`。订阅与模板列表返回 `data.items/data.total`,source 与 binding 列表直接返回数组,其余接口返回单个对象或布尔值;全部 `POST` 使用 HTTP 200。账号离线或 OneBot 不可用时 targets 仍返回 HTTP 200 和 `{ available: false, options: [], reasonCode }`。 + +请求采用严格白名单:Snowflake/外键 ID 是 1–24 位正十进制字符串,`selfId` 和 QQ 目标 ID 必须匹配 `^[1-9]\d{4,19}$`,禁止 number 转换。订阅 `name` 为 1–100 字符且不能全空白,`sourceConfig` 必须且仅含字符串 `portForwardId/ddnsRecordId`;模板 `name` 同限,`content` 最多 2,000 Unicode 字符;`remark` 最多 500 字符。Binding 必须含 1–100 个严格嵌套 target,类型仅 `group/private`,`targetName` 最多 120 字符。Body、query、path 及嵌套对象的未知字段都会拒绝,query boolean 只接受字面量 `true/false`。 + +响应仅返回管理契约字段:source definition/field/variable 白名单;STUN 的 port-forward/DDNS 候选白名单;subscription、template、preview、binding/target 和 target option 视图。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。 + ### NapCat Runtime Profile | 方法 | 路径 | 说明 | diff --git a/README.md b/README.md index 1171f64..4779683 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 | | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | -| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | +| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 | | `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | | `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | | `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 | @@ -84,6 +84,8 @@ System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事 同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值,AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值;DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL,并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git;`DNSPOD` 是腾讯云官方 SDK 的技术服务名。 +QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`,Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由;管理响应仅返回字段白名单,不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。 + NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts b/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts new file mode 100644 index 0000000..c23f676 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller.ts @@ -0,0 +1,196 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { QqbotAccountMessagePushService } from '../../application/message-push/qqbot-account-message-push.service'; +import { QqbotMessageTargetOptionsService } from '../../application/message-push/qqbot-message-target-options.service'; +import { + AccountMessagePushBindingParamDto, + AccountMessagePushParamDto, + MessagePublishBindingInputDto, + MessagePushEnabledDto, +} from './qqbot-message-push.dto'; +import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; +import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard'; +import type { + QqbotMessagePublishBindingView, + QqbotMessagePushTargetOptionsResponse, +} from './qqbot-message-push.types'; + +/** + * Narrows one account binding to the public management view and target fields. + * @param view - Detached account binding service result. + * @returns Only the locked binding and nested target response fields. + */ +const allowlistBinding = ( + view: QqbotMessagePublishBindingView, +): QqbotMessagePublishBindingView => ({ + available: view.available, + createTime: view.createTime, + enabled: view.enabled, + id: view.id, + invalidReasonCode: view.invalidReasonCode, + sourceKey: view.sourceKey, + sourceName: view.sourceName, + subscriptionId: view.subscriptionId, + subscriptionName: view.subscriptionName, + targets: view.targets.map((target) => ({ + enabled: target.enabled, + id: target.id, + targetId: target.targetId, + targetName: target.targetName, + targetType: target.targetType, + })), + templateId: view.templateId, + templateName: view.templateName, + updateTime: view.updateTime, +}); + +/** + * Narrows OneBot candidates to stable labels and string identifiers only. + * @param value - Safe target-option service result. + * @returns Detached availability result without runtime/provider objects. + */ +const allowlistTargetOptions = ( + value: QqbotMessagePushTargetOptionsResponse, +): QqbotMessagePushTargetOptionsResponse => ({ + available: value.available, + options: value.options.map((option) => ({ + label: option.label, + targetId: option.targetId, + targetType: option.targetType, + })), + reasonCode: value.reasonCode, +}); + +@Controller('qqbot/accounts/:selfId/message-push') +@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) +@UsePipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true, + }), +) +export class QqbotAccountMessagePushController { + /** + * Initializes strict account-scoped binding and target management routes. + * @param bindingService - Account binding lifecycle service. + * @param targetOptionsService - HTTP-safe OneBot candidate service. + */ + constructor( + private readonly bindingService: QqbotAccountMessagePushService, + private readonly targetOptionsService: QqbotMessageTargetOptionsService, + ) {} + + /** + * Lists binding views for exactly one account. + * @param params - Validated QQ account route identity. + */ + @Get('bindings') + @QqbotMessagePushPermission('QqBot:Account:MessagePush:List') + async listBindings(@Param() params: AccountMessagePushParamDto) { + const views = await this.bindingService.listBindings(params.selfId); + return vbenSuccess(views.map(allowlistBinding)); + } + + /** + * Creates one binding for exactly one account. + * @param params - Validated QQ account route identity. + * @param body - Strict subscription, template, target, and enabled input. + */ + @Post('bindings') + @HttpCode(HttpStatus.OK) + @QqbotMessagePushPermission('QqBot:Account:MessagePush:Create') + async createBinding( + @Param() params: AccountMessagePushParamDto, + @Body() body: MessagePublishBindingInputDto, + ) { + return vbenSuccess( + allowlistBinding( + await this.bindingService.createBinding(params.selfId, body), + ), + ); + } + + /** + * Replaces one binding for exactly one account. + * @param params - Validated QQ account and binding route identities. + * @param body - Strict subscription, template, target, and enabled input. + */ + @Put('bindings/:id') + @QqbotMessagePushPermission('QqBot:Account:MessagePush:Update') + async updateBinding( + @Param() params: AccountMessagePushBindingParamDto, + @Body() body: MessagePublishBindingInputDto, + ) { + return vbenSuccess( + allowlistBinding( + await this.bindingService.updateBinding(params.selfId, params.id, body), + ), + ); + } + + /** + * Toggles one binding for exactly one account. + * @param params - Validated QQ account and binding route identities. + * @param body - Required JSON boolean state. + */ + @Put('bindings/:id/enabled') + @QqbotMessagePushPermission('QqBot:Account:MessagePush:Toggle') + async toggleBinding( + @Param() params: AccountMessagePushBindingParamDto, + @Body() body: MessagePushEnabledDto, + ) { + return vbenSuccess( + allowlistBinding( + await this.bindingService.setBindingEnabled( + params.selfId, + params.id, + body.enabled, + ), + ), + ); + } + + /** + * Soft-deletes one binding for exactly one account. + * @param params - Validated QQ account and binding route identities. + */ + @Delete('bindings/:id') + @QqbotMessagePushPermission('QqBot:Account:MessagePush:Delete') + async removeBinding(@Param() params: AccountMessagePushBindingParamDto) { + return vbenSuccess( + await this.bindingService.removeBinding(params.selfId, params.id), + ); + } + + /** + * Lists HTTP-safe group and friend candidates for exactly one account. + * @param params - Validated QQ account route identity. + */ + @Get('targets') + @QqbotMessagePushPermission( + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ) + async listTargets(@Param() params: AccountMessagePushParamDto) { + return vbenSuccess( + allowlistTargetOptions( + await this.targetOptionsService.listTargetOptions(params.selfId), + ), + ); + } +} diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator.ts new file mode 100644 index 0000000..d8ee4e5 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from '@nestjs/common'; + +export const QQBOT_MESSAGE_PUSH_PERMISSION = 'qqbot_message_push_permission'; + +/** + * Declares route auth codes using OR semantics for the message-push permission guard. + * @param authCodes - Exact active Admin menu auth codes accepted by the route. + * @returns Nest route metadata decorator consumed after JWT authentication. + */ +export const QqbotMessagePushPermission = (...authCodes: string[]) => + SetMetadata(QQBOT_MESSAGE_PUSH_PERMISSION, authCodes); diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard.ts new file mode 100644 index 0000000..bdb5668 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard.ts @@ -0,0 +1,63 @@ +import { + CanActivate, + ExecutionContext, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { throwVbenError } from '@/common'; +import type { AdminRequest } from '@/modules/admin/contract/admin.types'; +import { QQBOT_MESSAGE_PUSH_PERMISSION } from './qqbot-message-push-permission.decorator'; + +@Injectable() +export class QqbotMessagePushPermissionGuard implements CanActivate { + /** + * Initializes the fail-closed route permission guard. + * @param reflector - Nest metadata reader used for handler and controller permissions. + */ + constructor(private readonly reflector: Reflector) {} + + /** + * Allows an active super role or any exact active menu code declared by the route. + * @param context - Current authenticated HTTP request execution context. + * @returns `true` when the request's database-backed Admin roles grant the route. + * @throws {HttpException} Vben-safe HTTP 403 for missing metadata, user, or permission. + */ + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride( + QQBOT_MESSAGE_PUSH_PERMISSION, + [context.getHandler(), context.getClass()], + ); + if (!required?.length) this.forbidden(); + + const request = context.switchToHttp().getRequest(); + const activeRoles = (request.adminUser?.roles || []).filter( + (role) => !role.isDeleted && role.status === 1, + ); + if (activeRoles.some((role) => role.roleCode === 'super')) return true; + + const authCodes = new Set( + activeRoles.flatMap((role) => + (role.menus || []) + .filter( + (menu) => + !menu.isDeleted && + menu.status === 1 && + typeof menu.authCode === 'string' && + menu.authCode.length > 0, + ) + .map((menu) => menu.authCode), + ), + ); + if (required.some((authCode) => authCodes.has(authCode))) return true; + this.forbidden(); + } + + /** + * Throws the project-standard non-sensitive authorization response. + * @throws {HttpException} Always throws HTTP 403. + */ + private forbidden(): never { + return throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN); + } +} diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts new file mode 100644 index 0000000..e0f5d86 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller.ts @@ -0,0 +1,383 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { vbenPage, vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { QqbotMessageSubscriptionService } from '../../application/message-push/qqbot-message-subscription.service'; +import { QqbotMessageTemplateService } from '../../application/message-push/qqbot-message-template.service'; +import { SystemMessageSourceRegistry } from '../../application/message-push/system-message-source.registry'; +import type { + MessageSubscriptionView, + MessageTemplatePreview, + MessageTemplateView, + StunMappingPortChangedOptionsResponse, + SystemMessageSourceDefinition, +} from './qqbot-message-push.types'; +import { + MessagePushEnabledDto, + MessagePushIdParamDto, + MessagePushSourceParamDto, + MessageSubscriptionInputDto, + MessageSubscriptionListQueryDto, + MessageTemplateInputDto, + MessageTemplateListQueryDto, + MessageTemplatePreviewDto, +} from './qqbot-message-push.dto'; +import { QqbotMessagePushPermission } from './qqbot-message-push-permission.decorator'; +import { QqbotMessagePushPermissionGuard } from './qqbot-message-push-permission.guard'; + +const SOURCE_READ_PERMISSIONS = [ + 'QqBot:MessageSubscription:List', + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:MessageTemplate:List', + 'QqBot:MessageTemplate:Create', + 'QqBot:MessageTemplate:Update', + 'QqBot:MessageTemplate:Preview', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', +] as const; + +/** + * Returns an allowlisted detached source definition without exposing its adapter. + * @param definition - Registered source definition owned by an internal adapter. + * @returns A cloned definition containing only the public management contract. + */ +const cloneSourceDefinition = ( + definition: SystemMessageSourceDefinition, +): SystemMessageSourceDefinition => ({ + description: definition.description, + displayName: definition.displayName, + sourceKey: definition.sourceKey, + subscriptionFields: definition.subscriptionFields.map((field) => ({ + ...(field.dependsOn === undefined ? {} : { dependsOn: field.dependsOn }), + key: field.key, + label: field.label, + optionCollection: field.optionCollection, + required: field.required, + type: field.type, + })), + variables: definition.variables.map((variable) => ({ + description: variable.description, + example: variable.example, + key: variable.key, + label: variable.label, + type: variable.type, + })), + version: definition.version, +}); + +/** + * Narrows weakly typed adapter options to the locked STUN management response. + * @param value - Source adapter result after its domain-side eligibility checks. + * @returns Detached port-forward and DDNS option rows with public fields only. + */ +const allowlistStunOptions = ( + value: Record, +): StunMappingPortChangedOptionsResponse => { + const input = value as unknown as StunMappingPortChangedOptionsResponse; + return { + ddnsRecords: input.ddnsRecords.map((item) => ({ + disabledReasonCode: item.disabledReasonCode, + eligible: item.eligible, + fqdn: item.fqdn, + id: item.id, + name: item.name, + portForwardId: item.portForwardId, + })), + portForwards: input.portForwards.map((item) => ({ + disabledReasonCode: item.disabledReasonCode, + eligible: item.eligible, + externalPort: item.externalPort, + id: item.id, + internalPort: item.internalPort, + name: item.name, + protocol: item.protocol, + })), + }; +}; + +/** + * Narrows one subscription result to the public management view. + * @param view - Detached service result that may evolve independently of HTTP. + * @returns Only the locked subscription response fields and STUN config keys. + */ +const allowlistSubscription = ( + view: MessageSubscriptionView, +): MessageSubscriptionView => ({ + createTime: view.createTime, + enabled: view.enabled, + id: view.id, + invalidReasonCode: view.invalidReasonCode, + name: view.name, + remark: view.remark, + sourceConfig: { + ddnsRecordId: view.sourceConfig.ddnsRecordId, + portForwardId: view.sourceConfig.portForwardId, + }, + sourceKey: view.sourceKey, + sourceName: view.sourceName, + sourceSummary: view.sourceSummary, + updateTime: view.updateTime, + valid: view.valid, +}); + +/** + * Narrows one template result to the public management view. + * @param view - Detached service result that may evolve independently of HTTP. + * @returns Only the locked template response fields. + */ +const allowlistTemplate = (view: MessageTemplateView): MessageTemplateView => ({ + content: view.content, + createTime: view.createTime, + enabled: view.enabled, + id: view.id, + name: view.name, + referenceCount: view.referenceCount, + remark: view.remark, + sourceKey: view.sourceKey, + sourceName: view.sourceName, + updateTime: view.updateTime, +}); + +/** + * Narrows a renderer preview to the public message and example variable values. + * @param preview - Template service preview result. + * @returns Detached preview without parser or event internals. + */ +const allowlistPreview = ( + preview: MessageTemplatePreview, +): MessageTemplatePreview => ({ + renderedMessage: preview.renderedMessage, + variables: structuredClone(preview.variables), +}); + +@Controller('qqbot/message-push') +@UseGuards(JwtAuthGuard, QqbotMessagePushPermissionGuard) +@UsePipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true, + }), +) +export class QqbotMessagePushController { + /** + * Initializes global source, subscription, and template management routes. + * @param sourceRegistry - Process-local public source definitions and adapters. + * @param subscriptionService - Global subscription lifecycle service. + * @param templateService - Global template lifecycle and preview service. + */ + constructor( + private readonly sourceRegistry: SystemMessageSourceRegistry, + private readonly subscriptionService: QqbotMessageSubscriptionService, + private readonly templateService: QqbotMessageTemplateService, + ) {} + + /** Lists detached source definitions available to authorized management flows. */ + @Get('sources') + @QqbotMessagePushPermission(...SOURCE_READ_PERMISSIONS) + listSources() { + return vbenSuccess(this.sourceRegistry.list().map(cloneSourceDefinition)); + } + + /** + * Returns the concrete STUN configuration candidates without provider state. + */ + @Get('sources/network.stun.mapping-port-changed/options') + @QqbotMessagePushPermission( + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ) + async listStunSourceOptions() { + const result = await this.sourceRegistry + .get('network.stun.mapping-port-changed') + .listSubscriptionOptions(); + return vbenSuccess(allowlistStunOptions(result)); + } + + /** + * Reads one known public source definition. + * @param params - Validated source key route parameters. + */ + @Get('sources/:sourceKey') + @QqbotMessagePushPermission(...SOURCE_READ_PERMISSIONS) + sourceDetail(@Param() params: MessagePushSourceParamDto) { + const definition = this.sourceRegistry.get(params.sourceKey).definition; + return vbenSuccess(cloneSourceDefinition(definition)); + } + + /** + * Pages global subscriptions. + * @param query - Strict name, source, enabled, and pagination filters. + */ + @Get('subscriptions') + @QqbotMessagePushPermission( + 'QqBot:MessageSubscription:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ) + async pageSubscriptions(@Query() query: MessageSubscriptionListQueryDto) { + const page = await this.subscriptionService.page(query); + return vbenPage(page.items.map(allowlistSubscription), page.total); + } + + /** + * Creates one global subscription. + * @param body - Complete strict STUN subscription input. + */ + @Post('subscriptions') + @HttpCode(HttpStatus.OK) + @QqbotMessagePushPermission('QqBot:MessageSubscription:Create') + async createSubscription(@Body() body: MessageSubscriptionInputDto) { + return vbenSuccess( + allowlistSubscription(await this.subscriptionService.create(body)), + ); + } + + /** + * Replaces one global subscription. + * @param params - Validated string Snowflake route identity. + * @param body - Complete strict STUN subscription input. + */ + @Put('subscriptions/:id') + @QqbotMessagePushPermission('QqBot:MessageSubscription:Update') + async updateSubscription( + @Param() params: MessagePushIdParamDto, + @Body() body: MessageSubscriptionInputDto, + ) { + return vbenSuccess( + allowlistSubscription( + await this.subscriptionService.update(params.id, body), + ), + ); + } + + /** + * Toggles one global subscription. + * @param params - Validated string Snowflake route identity. + * @param body - Required JSON boolean state. + */ + @Put('subscriptions/:id/enabled') + @QqbotMessagePushPermission('QqBot:MessageSubscription:Toggle') + async toggleSubscription( + @Param() params: MessagePushIdParamDto, + @Body() body: MessagePushEnabledDto, + ) { + return vbenSuccess( + allowlistSubscription( + await this.subscriptionService.setEnabled(params.id, body.enabled), + ), + ); + } + + /** + * Soft-deletes one global subscription. + * @param params - Validated string Snowflake route identity. + */ + @Delete('subscriptions/:id') + @QqbotMessagePushPermission('QqBot:MessageSubscription:Delete') + async removeSubscription(@Param() params: MessagePushIdParamDto) { + return vbenSuccess(await this.subscriptionService.remove(params.id)); + } + + /** + * Pages global templates. + * @param query - Strict name, source, enabled, and pagination filters. + */ + @Get('templates') + @QqbotMessagePushPermission( + 'QqBot:MessageTemplate:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ) + async pageTemplates(@Query() query: MessageTemplateListQueryDto) { + const page = await this.templateService.page(query); + return vbenPage(page.items.map(allowlistTemplate), page.total); + } + + /** + * Creates one global template. + * @param body - Complete strict source template input. + */ + @Post('templates') + @HttpCode(HttpStatus.OK) + @QqbotMessagePushPermission('QqBot:MessageTemplate:Create') + async createTemplate(@Body() body: MessageTemplateInputDto) { + return vbenSuccess( + allowlistTemplate(await this.templateService.create(body)), + ); + } + + /** + * Replaces one global template. + * @param params - Validated string Snowflake route identity. + * @param body - Complete strict source template input. + */ + @Put('templates/:id') + @QqbotMessagePushPermission('QqBot:MessageTemplate:Update') + async updateTemplate( + @Param() params: MessagePushIdParamDto, + @Body() body: MessageTemplateInputDto, + ) { + return vbenSuccess( + allowlistTemplate(await this.templateService.update(params.id, body)), + ); + } + + /** + * Toggles one global template. + * @param params - Validated string Snowflake route identity. + * @param body - Required JSON boolean state. + */ + @Put('templates/:id/enabled') + @QqbotMessagePushPermission('QqBot:MessageTemplate:Toggle') + async toggleTemplate( + @Param() params: MessagePushIdParamDto, + @Body() body: MessagePushEnabledDto, + ) { + return vbenSuccess( + allowlistTemplate( + await this.templateService.setEnabled(params.id, body.enabled), + ), + ); + } + + /** + * Soft-deletes one unreferenced global template. + * @param params - Validated string Snowflake route identity. + */ + @Delete('templates/:id') + @QqbotMessagePushPermission('QqBot:MessageTemplate:Delete') + async removeTemplate(@Param() params: MessagePushIdParamDto) { + return vbenSuccess(await this.templateService.remove(params.id)); + } + + /** + * Safely renders one unsaved template with source examples. + * @param body - Strict source key and content only. + */ + @Post('templates/preview') + @HttpCode(HttpStatus.OK) + @QqbotMessagePushPermission('QqBot:MessageTemplate:Preview') + previewTemplate(@Body() body: MessageTemplatePreviewDto) { + return vbenSuccess(allowlistPreview(this.templateService.preview(body))); + } +} diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts new file mode 100644 index 0000000..da6d059 --- /dev/null +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.dto.ts @@ -0,0 +1,201 @@ +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsDefined, + IsIn, + IsInt, + IsObject, + IsOptional, + IsString, + Length, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/; +export const QQ_TARGET_ID_PATTERN = /^[1-9]\d{4,19}$/; +const SOURCE_KEY_MAX_LENGTH = 128; + +/** + * Converts only literal query booleans, leaving every other value for `IsBoolean` to reject. + * @param value - Raw query-string value supplied by Nest. + * @returns A boolean for `true`/`false`, otherwise the original invalid value. + */ +const strictQueryBoolean = ({ value }: { value: unknown }): unknown => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; +}; + +export class MessagePushSourceParamDto { + @IsString() + @Length(1, SOURCE_KEY_MAX_LENGTH) + sourceKey: string; +} + +export class MessagePushIdParamDto { + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + id: string; +} + +export class AccountMessagePushParamDto { + @IsString() + @Matches(QQ_TARGET_ID_PATTERN) + selfId: string; +} + +export class AccountMessagePushBindingParamDto extends AccountMessagePushParamDto { + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + id: string; +} + +export class MessagePushListQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageNo?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @Length(1, SOURCE_KEY_MAX_LENGTH) + sourceKey?: string; + + @IsOptional() + @Transform(strictQueryBoolean) + @IsBoolean() + enabled?: boolean; +} + +export class MessageSubscriptionListQueryDto extends MessagePushListQueryDto {} + +export class MessageTemplateListQueryDto extends MessagePushListQueryDto {} + +export class StunMappingPortChangedSubscriptionConfigDto { + [key: string]: unknown; + + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + portForwardId: string; + + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + ddnsRecordId: string; +} + +export class MessageSubscriptionInputDto { + @IsString() + @Length(1, 100) + @Matches(/\S/) + name: string; + + @IsString() + @Length(1, SOURCE_KEY_MAX_LENGTH) + sourceKey: string; + + @ValidateNested() + @Type(() => StunMappingPortChangedSubscriptionConfigDto) + @IsDefined() + @IsObject() + sourceConfig: StunMappingPortChangedSubscriptionConfigDto; + + @IsBoolean() + enabled: boolean; + + @IsOptional() + @IsString() + @MaxLength(500) + remark?: string; +} + +export class MessageTemplateInputDto { + @IsString() + @Length(1, 100) + @Matches(/\S/) + name: string; + + @IsString() + @Length(1, SOURCE_KEY_MAX_LENGTH) + sourceKey: string; + + @IsString() + @MaxLength(2000) + content: string; + + @IsBoolean() + enabled: boolean; + + @IsOptional() + @IsString() + @MaxLength(500) + remark?: string; +} + +export class MessageTemplatePreviewDto { + @IsString() + @Length(1, SOURCE_KEY_MAX_LENGTH) + sourceKey: string; + + @IsString() + @MaxLength(2000) + content: string; +} + +export class MessagePushEnabledDto { + @IsBoolean() + enabled: boolean; +} + +export class MessagePublishTargetInputDto { + @IsIn(['group', 'private']) + targetType: 'group' | 'private'; + + @IsString() + @Matches(QQ_TARGET_ID_PATTERN) + targetId: string; + + @IsOptional() + @IsString() + @MaxLength(120) + targetName?: string; +} + +export class MessagePublishBindingInputDto { + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + subscriptionId: string; + + @IsString() + @Matches(SNOWFLAKE_ID_PATTERN) + templateId: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => MessagePublishTargetInputDto) + targets: MessagePublishTargetInputDto[]; + + @IsBoolean() + enabled: boolean; +} diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index abfc727..a7d3ba0 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -61,6 +61,9 @@ import { SYSTEM_MESSAGE_DELIVERY_COORDINATOR, SYSTEM_MESSAGE_EVENT_STAGER, } from './contract/message-push/qqbot-message-push.types'; +import { QqbotMessagePushController } from './contract/message-push/qqbot-message-push.controller'; +import { QqbotAccountMessagePushController } from './contract/message-push/qqbot-account-message-push.controller'; +import { QqbotMessagePushPermissionGuard } from './contract/message-push/qqbot-message-push-permission.guard'; export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract'; @@ -87,9 +90,11 @@ export const QQBOT_CORE_ENTITIES = [ export const QQBOT_CORE_CONTROLLERS = [ QqbotAccountController, + QqbotAccountMessagePushController, QqbotCommandController, QqbotDashboardController, QqbotMessageController, + QqbotMessagePushController, QqbotPermissionController, QqbotRuleController, QqbotSendController, @@ -114,6 +119,7 @@ export const QQBOT_CORE_PROVIDERS = [ QqbotMessageTemplateService, QqbotAccountMessagePushService, QqbotMessageTargetOptionsService, + QqbotMessagePushPermissionGuard, QqbotAccountService, QqbotBusService, QqbotCommandEngineService, diff --git a/src/modules/qqbot/core/schema/README.md b/src/modules/qqbot/core/schema/README.md index 95a99eb..5fc52a7 100644 --- a/src/modules/qqbot/core/schema/README.md +++ b/src/modules/qqbot/core/schema/README.md @@ -17,12 +17,28 @@ Primary tables: - `qqbot_blocklist` - `qqbot_rule` - `qqbot_send_log` +- `qqbot_message_subscription` +- `qqbot_message_template` +- `qqbot_message_publish_binding` +- `qqbot_message_publish_target` +- `qqbot_message_event` +- `qqbot_message_delivery` Seed linkage: - Online command rows bind to accounts through `qqbot_account_ability`. - Core command rows refer to plugin capabilities by `plugin_key` and `operation_key`; execution is delegated through `QQBOT_PLUGIN_EXECUTION_PORT`. +- A subscription owns one canonical active source/config key; soft deletion + clears that unique key so historical rows remain available. +- A live publish binding links one account and subscription to one compatible + template. Any live binding prevents its template from being deleted. +- Binding `active_key` uniquely identifies account/subscription ownership; + target `active_key` uniquely identifies binding/type/target ownership. +- Event `event_id` is globally unique. Delivery uniqueness on + `(message_event_id, publish_target_id)` makes fan-out idempotent. +- Event and delivery dispatch/lease indexes support durable worker claims, + retries, lease recovery, and source-resource supersession ordering. Verification SQL: @@ -30,4 +46,34 @@ Verification SQL: SELECT COUNT(*) FROM qqbot_account WHERE is_deleted = 0; SELECT COUNT(*) FROM qqbot_command WHERE is_deleted = 0; SELECT COUNT(*) FROM qqbot_account_ability WHERE is_deleted = 0; +SELECT COUNT(*) FROM qqbot_message_subscription WHERE is_deleted = 0; +SELECT COUNT(*) FROM qqbot_message_template WHERE is_deleted = 0; +SELECT COUNT(*) FROM qqbot_message_publish_binding WHERE is_deleted = 0; +SELECT COUNT(*) FROM qqbot_message_publish_target WHERE is_deleted = 0; + +SELECT table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery' + ) +ORDER BY table_name; + +SELECT table_name, index_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qqbot_message_subscription', + 'qqbot_message_template', + 'qqbot_message_publish_binding', + 'qqbot_message_publish_target', + 'qqbot_message_event', + 'qqbot_message_delivery' + ) +ORDER BY table_name, index_name; ``` diff --git a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts index 8f3a497..00ca4a5 100644 --- a/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-module-contract.spec.ts @@ -5,6 +5,7 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { getMetadataArgsStorage } from 'typeorm'; import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module'; +import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; import { DictModule } from '../../../../src/modules/admin/platform-config/dict/dict.module'; import { AppModule } from '../../../../src/app.module'; import { QqbotAccountController } from '../../../../src/modules/qqbot/core/contract/account/qqbot-account.controller'; @@ -14,6 +15,9 @@ import { QqbotMessageController } from '../../../../src/modules/qqbot/core/contr import { QqbotPermissionController } from '../../../../src/modules/qqbot/core/contract/permission/qqbot-permission.controller'; import { QqbotRuleController } from '../../../../src/modules/qqbot/core/contract/rule/qqbot-rule.controller'; import { QqbotSendController } from '../../../../src/modules/qqbot/core/contract/send/qqbot-send.controller'; +import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller'; +import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller'; +import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard'; import { SystemMessageEventStagerService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-event-stager.service'; import { SystemMessageFanoutService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-fanout.service'; import { SystemMessageDeliveryCoordinatorService } from '../../../../src/modules/qqbot/core/application/message-push/system-message-delivery-coordinator.service'; @@ -137,6 +141,41 @@ describe('QQBot core module contract', () => { ); }); + it('registers the strict message-push HTTP boundary without internal worker routes', () => { + const messagePushRoutes = collectControllerRoutes([ + QqbotMessagePushController, + QqbotAccountMessagePushController, + ]).map(routeKey); + + expect(messagePushRoutes).toEqual( + [ + 'DELETE /qqbot/accounts/:selfId/message-push/bindings/:id', + 'DELETE /qqbot/message-push/subscriptions/:id', + 'DELETE /qqbot/message-push/templates/:id', + 'GET /qqbot/accounts/:selfId/message-push/bindings', + 'GET /qqbot/accounts/:selfId/message-push/targets', + 'GET /qqbot/message-push/sources', + 'GET /qqbot/message-push/sources/:sourceKey', + 'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options', + 'GET /qqbot/message-push/subscriptions', + 'GET /qqbot/message-push/templates', + 'POST /qqbot/accounts/:selfId/message-push/bindings', + 'POST /qqbot/message-push/subscriptions', + 'POST /qqbot/message-push/templates', + 'POST /qqbot/message-push/templates/preview', + 'PUT /qqbot/accounts/:selfId/message-push/bindings/:id', + 'PUT /qqbot/accounts/:selfId/message-push/bindings/:id/enabled', + 'PUT /qqbot/message-push/subscriptions/:id', + 'PUT /qqbot/message-push/subscriptions/:id/enabled', + 'PUT /qqbot/message-push/templates/:id', + 'PUT /qqbot/message-push/templates/:id/enabled', + ].sort(), + ); + expect(messagePushRoutes.join('\n')).not.toMatch( + /\/(?:publish|events|deliveries|fanout|retry)(?:\/|$)/, + ); + }); + it('routes QQBot through the core module as the owning Nest boundary', () => { const legacyWrapperName = ['Qqbot', 'Module'].join(''); const legacyWrapperPath = join( @@ -195,6 +234,32 @@ describe('QQBot core module contract', () => { (provider) => provider === SystemMessageFanoutService, ), ).toHaveLength(1); + expect( + QQBOT_CORE_PROVIDERS.filter( + (provider) => + typeof provider === 'object' && + provider !== null && + 'provide' in provider && + provider.provide === SYSTEM_MESSAGE_EVENT_STAGER, + ), + ).toEqual([ + { + provide: SYSTEM_MESSAGE_EVENT_STAGER, + useExisting: SystemMessageEventStagerService, + }, + ]); + expect( + QQBOT_CORE_PROVIDERS.filter( + (provider) => provider === QqbotMessagePushPermissionGuard, + ), + ).toHaveLength(1); + expect(new Set(QQBOT_CORE_CONTROLLERS).size).toBe( + QQBOT_CORE_CONTROLLERS.length, + ); + expect(new Set(QQBOT_CORE_PROVIDERS).size).toBe( + QQBOT_CORE_PROVIDERS.length, + ); + expect(QQBOT_CORE_PROVIDERS).not.toContain(JwtAuthGuard); expect( QQBOT_CORE_PROVIDERS.filter( (provider) => provider === SystemMessageDeliveryRunnerService, @@ -241,9 +306,11 @@ describe('QQBot core module contract', () => { expect(QQBOT_CORE_CONTROLLERS).toEqual( expect.arrayContaining([ QqbotAccountController, + QqbotAccountMessagePushController, QqbotCommandController, QqbotDashboardController, QqbotMessageController, + QqbotMessagePushController, QqbotPermissionController, QqbotRuleController, QqbotSendController, diff --git a/test/modules/qqbot/message-push/qqbot-message-push-permission.guard.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-permission.guard.spec.ts new file mode 100644 index 0000000..48dc176 --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-push-permission.guard.spec.ts @@ -0,0 +1,273 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard'; +import { QqbotMessagePushPermission } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator'; + +type RoleInput = { + isDeleted?: boolean; + menus?: Array<{ + authCode?: string; + isDeleted?: boolean; + status?: number; + }>; + roleCode?: string; + status?: number; +}; + +/** Builds the smallest HTTP execution context used by the permission guard. */ +const contextFor = ( + roles: RoleInput[], + handler: () => void = () => undefined, +): ExecutionContext => + ({ + getClass: () => class TestController {}, + getHandler: () => handler, + switchToHttp: () => ({ + getRequest: () => ({ adminUser: { roles } }), + }), + }) as unknown as ExecutionContext; + +/** Creates a handler decorated with one exact management permission. */ +const handlerWithPermission = (...authCodes: string[]): (() => void) => { + class PermissionFixture { + @QqbotMessagePushPermission(...authCodes) + run(): void {} + } + return PermissionFixture.prototype.run; +}; + +describe('QqbotMessagePushPermissionGuard', () => { + const guard = new QqbotMessagePushPermissionGuard(new Reflector()); + const handler = handlerWithPermission('QqBot:MessageTemplate:Preview'); + + it('allows only an active non-deleted super assignment to bypass', () => { + expect( + guard.canActivate( + contextFor( + [{ isDeleted: false, roleCode: 'super', status: 1 }], + handler, + ), + ), + ).toBe(true); + expect(() => + guard.canActivate( + contextFor( + [{ isDeleted: true, roleCode: 'super', status: 1 }], + handler, + ), + ), + ).toThrow(HttpException); + expect(() => + guard.canActivate( + contextFor( + [{ isDeleted: false, roleCode: 'super', status: 0 }], + handler, + ), + ), + ).toThrow(HttpException); + }); + + it('uses only active roles and active exact-code menus', () => { + expect( + guard.canActivate( + contextFor( + [ + { + isDeleted: false, + menus: [ + { + authCode: 'QqBot:MessageSubscription:List', + isDeleted: false, + status: 1, + }, + { + authCode: 'QqBot:MessageTemplate:Preview', + isDeleted: false, + status: 1, + }, + ], + roleCode: 'operator', + status: 1, + }, + ], + handler, + ), + ), + ).toBe(true); + + for (const roles of [ + [ + { + isDeleted: true, + menus: [ + { + authCode: 'QqBot:MessageTemplate:Preview', + isDeleted: false, + status: 1, + }, + ], + status: 1, + }, + ], + [ + { + isDeleted: false, + menus: [ + { + authCode: 'QqBot:MessageTemplate:Preview', + isDeleted: false, + status: 1, + }, + ], + status: 0, + }, + ], + [ + { + isDeleted: false, + menus: [ + { + authCode: 'QqBot:MessageTemplate:Preview', + isDeleted: true, + status: 1, + }, + ], + status: 1, + }, + ], + [ + { + isDeleted: false, + menus: [ + { + authCode: 'QqBot:MessageTemplate:Preview', + isDeleted: false, + status: 0, + }, + ], + status: 1, + }, + ], + ]) { + expect(() => guard.canActivate(contextFor(roles, handler))).toThrow( + HttpException, + ); + } + }); + + it('fails closed when route metadata is missing or empty', () => { + expect(() => guard.canActivate(contextFor([]))).toThrow(HttpException); + + class EmptyPermissionFixture { + @QqbotMessagePushPermission() + run(): void {} + } + expect(() => + guard.canActivate(contextFor([], EmptyPermissionFixture.prototype.run)), + ).toThrow(HttpException); + }); + + it.each([ + [ + 'source read', + [ + 'QqBot:MessageSubscription:List', + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:MessageTemplate:List', + 'QqBot:MessageTemplate:Create', + 'QqBot:MessageTemplate:Update', + 'QqBot:MessageTemplate:Preview', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + ], + [ + 'source options', + [ + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + ], + [ + 'subscription read', + [ + 'QqBot:MessageSubscription:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + ], + [ + 'template read', + [ + 'QqBot:MessageTemplate:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + ], + [ + 'target read', + ['QqBot:Account:MessagePush:Create', 'QqBot:Account:MessagePush:Update'], + ], + ])('accepts every independent %s OR permission', (_label, authCodes) => { + const routeHandler = handlerWithPermission(...authCodes); + authCodes.forEach((authCode) => { + expect( + guard.canActivate( + contextFor( + [ + { + isDeleted: false, + menus: [{ authCode, isDeleted: false, status: 1 }], + roleCode: 'operator', + status: 1, + }, + ], + routeHandler, + ), + ), + ).toBe(true); + }); + }); + + it.each([ + ['QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:List'], + ['QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Create'], + ['QqBot:MessageTemplate:Delete', 'QqBot:MessageTemplate:List'], + ['QqBot:Account:MessagePush:Toggle', 'QqBot:Account:MessagePush:Update'], + ])( + 'does not let neighboring code %s grant mutation %s', + (required, neighboring) => { + expect(() => + guard.canActivate( + contextFor( + [ + { + isDeleted: false, + menus: [{ authCode: neighboring, isDeleted: false, status: 1 }], + roleCode: 'operator', + status: 1, + }, + ], + handlerWithPermission(required), + ), + ), + ).toThrow(HttpException); + }, + ); + + it('fails closed when JwtAuthGuard did not populate adminUser', () => { + const missingUserContext = { + getClass: () => class TestController {}, + getHandler: () => handler, + switchToHttp: () => ({ getRequest: () => ({}) }), + } as unknown as ExecutionContext; + expect(() => guard.canActivate(missingUserContext)).toThrow(HttpException); + }); +}); diff --git a/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts new file mode 100644 index 0000000..273e36e --- /dev/null +++ b/test/modules/qqbot/message-push/qqbot-message-push.controller.spec.ts @@ -0,0 +1,851 @@ +import { GUARDS_METADATA, PIPES_METADATA } from '@nestjs/common/constants'; +import { ValidationPipe, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; +import { QqbotAccountMessagePushService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-account-message-push.service'; +import { QqbotMessageSubscriptionService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-subscription.service'; +import { QqbotMessageTargetOptionsService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-target-options.service'; +import { QqbotMessageTemplateService } from '../../../../src/modules/qqbot/core/application/message-push/qqbot-message-template.service'; +import { SystemMessageSourceRegistry } from '../../../../src/modules/qqbot/core/application/message-push/system-message-source.registry'; +import { QqbotAccountMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-account-message-push.controller'; +import { QqbotMessagePushController } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push.controller'; +import { QqbotMessagePushPermissionGuard } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.guard'; +import { QQBOT_MESSAGE_PUSH_PERMISSION } from '../../../../src/modules/qqbot/core/contract/message-push/qqbot-message-push-permission.decorator'; +import { + collectControllerRoutes, + routeKey, +} from '../../../helpers/controller-route.helper'; + +const SOURCE_READ_PERMISSIONS = [ + 'QqBot:MessageSubscription:List', + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:MessageTemplate:List', + 'QqBot:MessageTemplate:Create', + 'QqBot:MessageTemplate:Update', + 'QqBot:MessageTemplate:Preview', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', +]; + +const EXPECTED_ROUTE_PERMISSIONS: Record = { + 'DELETE /qqbot/accounts/:selfId/message-push/bindings/:id': [ + 'QqBot:Account:MessagePush:Delete', + ], + 'DELETE /qqbot/message-push/subscriptions/:id': [ + 'QqBot:MessageSubscription:Delete', + ], + 'DELETE /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Delete'], + 'GET /qqbot/accounts/:selfId/message-push/bindings': [ + 'QqBot:Account:MessagePush:List', + ], + 'GET /qqbot/accounts/:selfId/message-push/targets': [ + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + 'GET /qqbot/message-push/sources': SOURCE_READ_PERMISSIONS, + 'GET /qqbot/message-push/sources/:sourceKey': SOURCE_READ_PERMISSIONS, + 'GET /qqbot/message-push/sources/network.stun.mapping-port-changed/options': [ + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + 'GET /qqbot/message-push/subscriptions': [ + 'QqBot:MessageSubscription:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + 'GET /qqbot/message-push/templates': [ + 'QqBot:MessageTemplate:List', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ], + 'POST /qqbot/accounts/:selfId/message-push/bindings': [ + 'QqBot:Account:MessagePush:Create', + ], + 'POST /qqbot/message-push/subscriptions': [ + 'QqBot:MessageSubscription:Create', + ], + 'POST /qqbot/message-push/templates': ['QqBot:MessageTemplate:Create'], + 'POST /qqbot/message-push/templates/preview': [ + 'QqBot:MessageTemplate:Preview', + ], + 'PUT /qqbot/accounts/:selfId/message-push/bindings/:id': [ + 'QqBot:Account:MessagePush:Update', + ], + 'PUT /qqbot/accounts/:selfId/message-push/bindings/:id/enabled': [ + 'QqBot:Account:MessagePush:Toggle', + ], + 'PUT /qqbot/message-push/subscriptions/:id': [ + 'QqBot:MessageSubscription:Update', + ], + 'PUT /qqbot/message-push/subscriptions/:id/enabled': [ + 'QqBot:MessageSubscription:Toggle', + ], + 'PUT /qqbot/message-push/templates/:id': ['QqBot:MessageTemplate:Update'], + 'PUT /qqbot/message-push/templates/:id/enabled': [ + 'QqBot:MessageTemplate:Toggle', + ], +}; + +const STRING_ID = '123456789012345678901234'; +const SELF_ID = '12345'; + +/** Builds one valid strict STUN subscription payload. */ +const subscriptionBody = () => ({ + enabled: true, + name: 'STUN port changed', + remark: 'local test', + sourceConfig: { + ddnsRecordId: STRING_ID, + portForwardId: '1234567890123456789', + }, + sourceKey: 'network.stun.mapping-port-changed', +}); + +/** Builds one valid strict template payload. */ +const templateBody = () => ({ + content: 'Endpoint: ${{endpoint}}', + enabled: true, + name: 'STUN template', + remark: 'local test', + sourceKey: 'network.stun.mapping-port-changed', +}); + +/** Builds one valid strict account binding payload with a selectable target count. */ +const bindingBody = (targetCount = 1) => ({ + enabled: true, + subscriptionId: STRING_ID, + targets: Array.from({ length: targetCount }, (_, index) => ({ + targetId: `${10000 + index}`, + targetName: `Target ${index}`, + targetType: index % 2 === 0 ? 'group' : 'private', + })), + templateId: '1234567890123456789', +}); + +/** Returns a complete public source definition plus an internal field to detect leakage. */ +const sourceDefinition = () => + ({ + credential: 'must-not-leak', + description: 'STUN endpoint port change', + displayName: 'STUN port changed', + sourceKey: 'network.stun.mapping-port-changed', + subscriptionFields: [ + { + credential: 'must-not-leak', + key: 'portForwardId', + label: 'Port forward', + optionCollection: 'portForwards', + required: true, + type: 'select', + }, + { + dependsOn: 'portForwardId', + key: 'ddnsRecordId', + label: 'DDNS', + optionCollection: 'ddnsRecords', + required: true, + type: 'select', + }, + ], + variables: [ + { + accessToken: 'must-not-leak', + description: 'Endpoint', + example: 'example.test:10000', + key: 'endpoint', + label: 'Endpoint', + type: 'string', + }, + ], + version: 1, + }) as never; + +/** Returns a detached subscription view plus persistence-only fields to detect leakage. */ +const subscriptionView = () => + ({ + activeKey: 'must-not-leak', + createTime: '2026-07-24T00:00:00.000Z', + enabled: true, + id: STRING_ID, + invalidReasonCode: null, + isDeleted: false, + name: 'STUN port changed', + remark: null, + sourceConfig: { + credential: 'must-not-leak', + ddnsRecordId: STRING_ID, + portForwardId: '1234567890123456789', + }, + sourceConfigDigest: 'must-not-leak', + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN port changed', + sourceSummary: 'summary', + updateTime: '2026-07-24T00:00:00.000Z', + valid: true, + }) as never; + +/** Returns a detached template view plus persistence-only fields to detect leakage. */ +const templateView = () => + ({ + activeKey: 'must-not-leak', + content: 'Endpoint: ${{endpoint}}', + createTime: '2026-07-24T00:00:00.000Z', + enabled: true, + id: STRING_ID, + isDeleted: false, + name: 'STUN template', + referenceCount: 1, + remark: null, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN port changed', + updateTime: '2026-07-24T00:00:00.000Z', + }) as never; + +/** Returns a detached binding view plus account and active-key fields to detect leakage. */ +const bindingView = () => + ({ + accountId: 'must-not-leak', + activeKey: 'must-not-leak', + available: true, + createTime: '2026-07-24T00:00:00.000Z', + enabled: true, + id: STRING_ID, + invalidReasonCode: null, + selfId: SELF_ID, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN port changed', + subscriptionId: STRING_ID, + subscriptionName: 'STUN port changed', + targets: [ + { + activeKey: 'must-not-leak', + bindingId: STRING_ID, + enabled: true, + id: '1234567890123456789', + targetId: '10000', + targetName: 'Target', + targetType: 'group', + }, + ], + templateId: '1234567890123456789', + templateName: 'STUN template', + updateTime: '2026-07-24T00:00:00.000Z', + }) as never; + +/** Recursively collects response object keys for sensitive-field assertions. */ +const collectKeys = (value: unknown): string[] => { + if (Array.isArray(value)) return value.flatMap(collectKeys); + if (!value || typeof value !== 'object') return []; + return Object.entries(value).flatMap(([key, nested]) => [ + key, + ...collectKeys(nested), + ]); +}; + +describe('QQBot message-push management controllers', () => { + let app: INestApplication; + let apiUrl: string; + + const registry = { + get: jest.fn(), + list: jest.fn(), + }; + const subscriptions = { + create: jest.fn(), + page: jest.fn(), + remove: jest.fn(), + setEnabled: jest.fn(), + update: jest.fn(), + }; + const templates = { + create: jest.fn(), + page: jest.fn(), + preview: jest.fn(), + remove: jest.fn(), + setEnabled: jest.fn(), + update: jest.fn(), + }; + const bindings = { + createBinding: jest.fn(), + listBindings: jest.fn(), + removeBinding: jest.fn(), + setBindingEnabled: jest.fn(), + updateBinding: jest.fn(), + }; + const targets = { + listTargetOptions: jest.fn(), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [ + QqbotMessagePushController, + QqbotAccountMessagePushController, + ], + providers: [ + QqbotMessagePushPermissionGuard, + { provide: SystemMessageSourceRegistry, useValue: registry }, + { provide: QqbotMessageSubscriptionService, useValue: subscriptions }, + { provide: QqbotMessageTemplateService, useValue: templates }, + { provide: QqbotAccountMessagePushService, useValue: bindings }, + { provide: QqbotMessageTargetOptionsService, useValue: targets }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(QqbotMessagePushPermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0, '127.0.0.1'); + apiUrl = await app.getUrl(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + const definition = sourceDefinition(); + registry.list.mockReturnValue([definition]); + registry.get.mockReturnValue({ + credential: 'must-not-leak', + definition, + listSubscriptionOptions: jest.fn().mockResolvedValue({ + credential: 'must-not-leak', + ddnsRecords: [ + { + credential: 'must-not-leak', + disabledReasonCode: null, + eligible: true, + fqdn: 'example.test', + id: STRING_ID, + name: 'DDNS', + portForwardId: '1234567890123456789', + }, + ], + portForwards: [ + { + accessToken: 'must-not-leak', + disabledReasonCode: null, + eligible: true, + externalPort: 10000, + id: '1234567890123456789', + internalPort: 10000, + name: 'STUN', + protocol: 'udp', + }, + ], + }), + }); + subscriptions.page.mockResolvedValue({ + items: [subscriptionView()], + total: 1, + }); + subscriptions.create.mockResolvedValue(subscriptionView()); + subscriptions.update.mockResolvedValue(subscriptionView()); + subscriptions.setEnabled.mockResolvedValue(subscriptionView()); + subscriptions.remove.mockResolvedValue(true); + templates.page.mockResolvedValue({ items: [templateView()], total: 1 }); + templates.create.mockResolvedValue(templateView()); + templates.update.mockResolvedValue(templateView()); + templates.setEnabled.mockResolvedValue(templateView()); + templates.remove.mockResolvedValue(true); + templates.preview.mockReturnValue({ + payload: 'must-not-leak', + renderedMessage: 'Endpoint: example.test:10000', + variables: { endpoint: 'example.test:10000' }, + }); + bindings.listBindings.mockResolvedValue([bindingView()]); + bindings.createBinding.mockResolvedValue(bindingView()); + bindings.updateBinding.mockResolvedValue(bindingView()); + bindings.setBindingEnabled.mockResolvedValue(bindingView()); + bindings.removeBinding.mockResolvedValue(true); + targets.listTargetOptions.mockResolvedValue({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('exposes exactly the approved 20 routes with exact permission metadata', () => { + const routes = collectControllerRoutes([ + QqbotMessagePushController, + QqbotAccountMessagePushController, + ]); + const routeKeys = routes.map(routeKey); + + expect(routeKeys).toEqual(Object.keys(EXPECTED_ROUTE_PERMISSIONS).sort()); + expect( + routeKeys.some((route) => + /\/(?:publish|events|deliveries|fanout|retry)(?:\/|$)/.test(route), + ), + ).toBe(false); + + routes.forEach((route) => { + const ControllerClass = + route.controllerName === QqbotMessagePushController.name + ? QqbotMessagePushController + : QqbotAccountMessagePushController; + const handler = ControllerClass.prototype[route.handlerName]; + expect( + Reflect.getMetadata(QQBOT_MESSAGE_PUSH_PERMISSION, handler), + ).toEqual(EXPECTED_ROUTE_PERMISSIONS[routeKey(route)]); + }); + }); + + it('uses page wrappers only for subscription and template pages', async () => { + const sourceResponse = await request(apiUrl) + .get('/qqbot/message-push/sources') + .expect(200); + const subscriptionResponse = await request(apiUrl) + .get('/qqbot/message-push/subscriptions') + .expect(200); + const templateResponse = await request(apiUrl) + .get('/qqbot/message-push/templates') + .expect(200); + const bindingResponse = await request(apiUrl) + .get('/qqbot/accounts/12345/message-push/bindings') + .expect(200); + + expect(Array.isArray(sourceResponse.body.data)).toBe(true); + expect(subscriptionResponse.body.data).toMatchObject({ total: 1 }); + expect(Array.isArray(subscriptionResponse.body.data.items)).toBe(true); + expect(templateResponse.body.data).toMatchObject({ total: 1 }); + expect(Array.isArray(templateResponse.body.data.items)).toBe(true); + expect(Array.isArray(bindingResponse.body.data)).toBe(true); + }); + + it('keeps unavailable target lookup at HTTP 200 with an exact safe shape', async () => { + const response = await request(apiUrl) + .get('/qqbot/accounts/12345/message-push/targets') + .expect(200); + + expect(response.body.data).toEqual({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }); + }); + + it('attaches both guards and a strict controller-local ValidationPipe', () => { + for (const ControllerClass of [ + QqbotMessagePushController, + QqbotAccountMessagePushController, + ]) { + expect(Reflect.getMetadata(GUARDS_METADATA, ControllerClass)).toEqual([ + JwtAuthGuard, + QqbotMessagePushPermissionGuard, + ]); + const pipes = Reflect.getMetadata(PIPES_METADATA, ControllerClass); + expect(pipes).toHaveLength(1); + expect(pipes[0]).toBeInstanceOf(ValidationPipe); + expect(pipes[0].validatorOptions).toMatchObject({ + forbidNonWhitelisted: true, + whitelist: true, + }); + expect(pipes[0].isTransformEnabled).toBe(true); + } + }); + + it('returns HTTP 200 and a Vben wrapper for every POST route', async () => { + const responses = await Promise.all([ + request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send(subscriptionBody()) + .expect(200), + request(apiUrl) + .post('/qqbot/message-push/templates') + .send(templateBody()) + .expect(200), + request(apiUrl) + .post('/qqbot/message-push/templates/preview') + .send({ + content: 'Endpoint: ${{endpoint}}', + sourceKey: 'network.stun.mapping-port-changed', + }) + .expect(200), + request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody()) + .expect(200), + ]); + responses.forEach((response) => { + expect(response.body).toEqual( + expect.objectContaining({ code: 200, msg: expect.any(String) }), + ); + }); + }); + + it.each([ + ['/qqbot/message-push/subscriptions', subscriptionBody()], + ['/qqbot/message-push/templates', templateBody()], + [ + '/qqbot/message-push/templates/preview', + { + content: 'Endpoint: ${{endpoint}}', + sourceKey: 'network.stun.mapping-port-changed', + }, + ], + [`/qqbot/accounts/${SELF_ID}/message-push/bindings`, bindingBody()], + ])('rejects unknown root request keys on POST %s', async (path, body) => { + await request(apiUrl) + .post(path) + .send({ ...body, credential: 'must-reject' }) + .expect(400); + }); + + it('rejects unknown enabled, sourceConfig, and target fields', async () => { + await request(apiUrl) + .put(`/qqbot/message-push/subscriptions/${STRING_ID}/enabled`) + .send({ enabled: false, secret: 'must-reject' }) + .expect(400); + await request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send({ + ...subscriptionBody(), + sourceConfig: { + ...subscriptionBody().sourceConfig, + credential: 'must-reject', + }, + }) + .expect(400); + const binding = bindingBody(); + binding.targets[0] = { + ...binding.targets[0], + accessToken: 'must-reject', + } as never; + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(binding) + .expect(400); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['array', []], + ])( + 'rejects %s sourceConfig instead of skipping nested validation', + async (_label, sourceConfig) => { + const body = subscriptionBody() as Record; + if (sourceConfig === undefined) { + delete body.sourceConfig; + } else { + body.sourceConfig = sourceConfig; + } + await request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send(body) + .expect(400); + }, + ); + + it('rejects numeric foreign IDs and preserves long decimal strings', async () => { + await request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send({ + ...subscriptionBody(), + sourceConfig: { + ddnsRecordId: 123, + portForwardId: '1234567890123456789', + }, + }) + .expect(400); + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send({ ...bindingBody(), subscriptionId: 123 }) + .expect(400); + + await request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send(subscriptionBody()) + .expect(200); + expect(subscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ + sourceConfig: expect.objectContaining({ ddnsRecordId: STRING_ID }), + }), + ); + expect( + typeof subscriptions.create.mock.calls[0][0].sourceConfig.ddnsRecordId, + ).toBe('string'); + }); + + it.each(['0', '-1', '1.5', '1e5', '%20123', '1234567890123456789012345'])( + 'rejects invalid Snowflake path id %s', + async (id) => { + await request(apiUrl) + .delete(`/qqbot/message-push/subscriptions/${id}`) + .expect(400); + }, + ); + + it.each(['0', '1234', '-12345', '1.2345', '1e9999', '123456789012345678901'])( + 'rejects invalid selfId %s', + async (selfId) => { + await request(apiUrl) + .get(`/qqbot/accounts/${selfId}/message-push/bindings`) + .expect(400); + }, + ); + + it('transforms only literal query booleans', async () => { + await request(apiUrl) + .get('/qqbot/message-push/subscriptions?enabled=false') + .expect(200); + expect(subscriptions.page).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + + for (const value of ['0', 'yes', '']) { + await request(apiUrl) + .get(`/qqbot/message-push/subscriptions?enabled=${value}`) + .expect(400); + } + }); + + it.each([ + ['target type', { targetType: 'channel' }], + ['short target id', { targetId: '1234' }], + ['zero target id', { targetId: '01234' }], + ['numeric target id', { targetId: 12345 }], + ])('rejects invalid %s', async (_label, override) => { + const body = bindingBody(); + body.targets[0] = { ...body.targets[0], ...override } as never; + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(body) + .expect(400); + }); + + it('enforces target count 1..100 with nested transformation', async () => { + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody(0)) + .expect(400); + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody(101)) + .expect(400); + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody(1)) + .expect(200); + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(bindingBody(100)) + .expect(200); + expect(bindings.createBinding.mock.calls.at(-1)?.[1].targets).toHaveLength( + 100, + ); + }); + + it('enforces name, remark, target name, and content boundaries', async () => { + for (const body of [ + { ...subscriptionBody(), name: ' '.repeat(2) }, + { ...subscriptionBody(), name: 'x'.repeat(101) }, + { ...subscriptionBody(), remark: 'x'.repeat(501) }, + ]) { + await request(apiUrl) + .post('/qqbot/message-push/subscriptions') + .send(body) + .expect(400); + } + await request(apiUrl) + .post('/qqbot/message-push/templates') + .send({ ...templateBody(), content: '😀'.repeat(2001) }) + .expect(400); + const binding = bindingBody(); + binding.targets[0].targetName = 'x'.repeat(121); + await request(apiUrl) + .post(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .send(binding) + .expect(400); + }); + + it('returns only exact public response fields and recursively excludes secrets', async () => { + targets.listTargetOptions.mockResolvedValueOnce({ + accessToken: 'must-not-leak', + available: true, + options: [ + { + credential: 'must-not-leak', + label: 'Target (10000)', + targetId: '10000', + targetType: 'group', + }, + ], + reasonCode: null, + }); + const responses = await Promise.all([ + request(apiUrl).get('/qqbot/message-push/sources').expect(200), + request(apiUrl) + .get('/qqbot/message-push/sources/network.stun.mapping-port-changed') + .expect(200), + request(apiUrl) + .get( + '/qqbot/message-push/sources/network.stun.mapping-port-changed/options', + ) + .expect(200), + request(apiUrl).get('/qqbot/message-push/subscriptions').expect(200), + request(apiUrl).get('/qqbot/message-push/templates').expect(200), + request(apiUrl) + .post('/qqbot/message-push/templates/preview') + .send({ + content: 'Endpoint: ${{endpoint}}', + sourceKey: 'network.stun.mapping-port-changed', + }) + .expect(200), + request(apiUrl) + .get(`/qqbot/accounts/${SELF_ID}/message-push/bindings`) + .expect(200), + request(apiUrl) + .get(`/qqbot/accounts/${SELF_ID}/message-push/targets`) + .expect(200), + ]); + const [ + sourceList, + sourceDetail, + options, + subscriptionPage, + templatePage, + preview, + bindingList, + targetOptions, + ] = responses.map((response) => response.body.data); + + expect(Object.keys(sourceList[0]).sort()).toEqual( + [ + 'description', + 'displayName', + 'sourceKey', + 'subscriptionFields', + 'variables', + 'version', + ].sort(), + ); + expect(Object.keys(sourceDetail).sort()).toEqual( + Object.keys(sourceList[0]).sort(), + ); + expect(Object.keys(sourceList[0].subscriptionFields[0]).sort()).toEqual( + ['key', 'label', 'optionCollection', 'required', 'type'].sort(), + ); + expect(Object.keys(sourceList[0].subscriptionFields[1]).sort()).toEqual( + [ + 'dependsOn', + 'key', + 'label', + 'optionCollection', + 'required', + 'type', + ].sort(), + ); + expect(Object.keys(sourceList[0].variables[0]).sort()).toEqual( + ['description', 'example', 'key', 'label', 'type'].sort(), + ); + expect(Object.keys(options).sort()).toEqual([ + 'ddnsRecords', + 'portForwards', + ]); + expect(Object.keys(options.ddnsRecords[0]).sort()).toEqual( + [ + 'disabledReasonCode', + 'eligible', + 'fqdn', + 'id', + 'name', + 'portForwardId', + ].sort(), + ); + expect(Object.keys(options.portForwards[0]).sort()).toEqual( + [ + 'disabledReasonCode', + 'eligible', + 'externalPort', + 'id', + 'internalPort', + 'name', + 'protocol', + ].sort(), + ); + expect(Object.keys(subscriptionPage.items[0]).sort()).toEqual( + [ + 'createTime', + 'enabled', + 'id', + 'invalidReasonCode', + 'name', + 'remark', + 'sourceConfig', + 'sourceKey', + 'sourceName', + 'sourceSummary', + 'updateTime', + 'valid', + ].sort(), + ); + expect(Object.keys(subscriptionPage.items[0].sourceConfig).sort()).toEqual([ + 'ddnsRecordId', + 'portForwardId', + ]); + expect(Object.keys(templatePage.items[0]).sort()).toEqual( + [ + 'content', + 'createTime', + 'enabled', + 'id', + 'name', + 'referenceCount', + 'remark', + 'sourceKey', + 'sourceName', + 'updateTime', + ].sort(), + ); + expect(Object.keys(preview).sort()).toEqual([ + 'renderedMessage', + 'variables', + ]); + expect(Object.keys(bindingList[0]).sort()).toEqual( + [ + 'available', + 'createTime', + 'enabled', + 'id', + 'invalidReasonCode', + 'sourceKey', + 'sourceName', + 'subscriptionId', + 'subscriptionName', + 'targets', + 'templateId', + 'templateName', + 'updateTime', + ].sort(), + ); + expect(Object.keys(bindingList[0].targets[0]).sort()).toEqual( + ['enabled', 'id', 'targetId', 'targetName', 'targetType'].sort(), + ); + expect(Object.keys(targetOptions).sort()).toEqual( + ['available', 'options', 'reasonCode'].sort(), + ); + expect(Object.keys(targetOptions.options[0]).sort()).toEqual( + ['label', 'targetId', 'targetType'].sort(), + ); + + const forbiddenKeys = + /accessToken|credential|secret|password|activeKey|sourceConfigDigest|payload|accountId|selfId|bindingId|isDeleted/i; + responses.forEach((response) => { + expect(collectKeys(response.body).join('\n')).not.toMatch(forbiddenKeys); + }); + }); +});