feat: 开放QQBot消息推送管理接口
This commit is contained in:
parent
d64d5aad49
commit
1c865319bf
33
API.md
33
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`。
|
托管 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
|
### NapCat Runtime Profile
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板和网络端口映射管理 |
|
||||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、系统消息源/订阅/模板/账号绑定、耐久投递、发送/接收日志和插件平台 |
|
||||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||||
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 |
|
| `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 的技术服务名。
|
同一模块提供腾讯云云解析 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/<pid>/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 <uin>` 快速登录参数,避免重启后退回无账号扫码;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 默认值。
|
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/<pid>/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 <uin>` 快速登录参数,避免重启后退回无账号扫码;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。
|
运行时发布时,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。
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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<string[]>(
|
||||||
|
QQBOT_MESSAGE_PUSH_PERMISSION,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!required?.length) this.forbidden();
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AdminRequest>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<string, unknown>,
|
||||||
|
): 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -61,6 +61,9 @@ import {
|
|||||||
SYSTEM_MESSAGE_DELIVERY_COORDINATOR,
|
SYSTEM_MESSAGE_DELIVERY_COORDINATOR,
|
||||||
SYSTEM_MESSAGE_EVENT_STAGER,
|
SYSTEM_MESSAGE_EVENT_STAGER,
|
||||||
} from './contract/message-push/qqbot-message-push.types';
|
} 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';
|
export { QQBOT_CORE_DOMAIN_CONTRACT } from './contract/qqbot-core.contract';
|
||||||
|
|
||||||
@ -87,9 +90,11 @@ export const QQBOT_CORE_ENTITIES = [
|
|||||||
|
|
||||||
export const QQBOT_CORE_CONTROLLERS = [
|
export const QQBOT_CORE_CONTROLLERS = [
|
||||||
QqbotAccountController,
|
QqbotAccountController,
|
||||||
|
QqbotAccountMessagePushController,
|
||||||
QqbotCommandController,
|
QqbotCommandController,
|
||||||
QqbotDashboardController,
|
QqbotDashboardController,
|
||||||
QqbotMessageController,
|
QqbotMessageController,
|
||||||
|
QqbotMessagePushController,
|
||||||
QqbotPermissionController,
|
QqbotPermissionController,
|
||||||
QqbotRuleController,
|
QqbotRuleController,
|
||||||
QqbotSendController,
|
QqbotSendController,
|
||||||
@ -114,6 +119,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
QqbotMessageTemplateService,
|
QqbotMessageTemplateService,
|
||||||
QqbotAccountMessagePushService,
|
QqbotAccountMessagePushService,
|
||||||
QqbotMessageTargetOptionsService,
|
QqbotMessageTargetOptionsService,
|
||||||
|
QqbotMessagePushPermissionGuard,
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotBusService,
|
QqbotBusService,
|
||||||
QqbotCommandEngineService,
|
QqbotCommandEngineService,
|
||||||
|
|||||||
@ -17,12 +17,28 @@ Primary tables:
|
|||||||
- `qqbot_blocklist`
|
- `qqbot_blocklist`
|
||||||
- `qqbot_rule`
|
- `qqbot_rule`
|
||||||
- `qqbot_send_log`
|
- `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:
|
Seed linkage:
|
||||||
|
|
||||||
- Online command rows bind to accounts through `qqbot_account_ability`.
|
- Online command rows bind to accounts through `qqbot_account_ability`.
|
||||||
- Core command rows refer to plugin capabilities by `plugin_key` and
|
- Core command rows refer to plugin capabilities by `plugin_key` and
|
||||||
`operation_key`; execution is delegated through `QQBOT_PLUGIN_EXECUTION_PORT`.
|
`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:
|
Verification SQL:
|
||||||
|
|
||||||
@ -30,4 +46,34 @@ Verification SQL:
|
|||||||
SELECT COUNT(*) FROM qqbot_account WHERE is_deleted = 0;
|
SELECT COUNT(*) FROM qqbot_account WHERE is_deleted = 0;
|
||||||
SELECT COUNT(*) FROM qqbot_command 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_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;
|
||||||
```
|
```
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { getMetadataArgsStorage } from 'typeorm';
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
import { AdminAuthGuardModule } from '../../../../src/modules/admin/identity/auth/admin-auth-guard.module';
|
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 { DictModule } from '../../../../src/modules/admin/platform-config/dict/dict.module';
|
||||||
import { AppModule } from '../../../../src/app.module';
|
import { AppModule } from '../../../../src/app.module';
|
||||||
import { QqbotAccountController } from '../../../../src/modules/qqbot/core/contract/account/qqbot-account.controller';
|
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 { QqbotPermissionController } from '../../../../src/modules/qqbot/core/contract/permission/qqbot-permission.controller';
|
||||||
import { QqbotRuleController } from '../../../../src/modules/qqbot/core/contract/rule/qqbot-rule.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 { 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 { 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 { 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';
|
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', () => {
|
it('routes QQBot through the core module as the owning Nest boundary', () => {
|
||||||
const legacyWrapperName = ['Qqbot', 'Module'].join('');
|
const legacyWrapperName = ['Qqbot', 'Module'].join('');
|
||||||
const legacyWrapperPath = join(
|
const legacyWrapperPath = join(
|
||||||
@ -195,6 +234,32 @@ describe('QQBot core module contract', () => {
|
|||||||
(provider) => provider === SystemMessageFanoutService,
|
(provider) => provider === SystemMessageFanoutService,
|
||||||
),
|
),
|
||||||
).toHaveLength(1);
|
).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(
|
expect(
|
||||||
QQBOT_CORE_PROVIDERS.filter(
|
QQBOT_CORE_PROVIDERS.filter(
|
||||||
(provider) => provider === SystemMessageDeliveryRunnerService,
|
(provider) => provider === SystemMessageDeliveryRunnerService,
|
||||||
@ -241,9 +306,11 @@ describe('QQBot core module contract', () => {
|
|||||||
expect(QQBOT_CORE_CONTROLLERS).toEqual(
|
expect(QQBOT_CORE_CONTROLLERS).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
QqbotAccountController,
|
QqbotAccountController,
|
||||||
|
QqbotAccountMessagePushController,
|
||||||
QqbotCommandController,
|
QqbotCommandController,
|
||||||
QqbotDashboardController,
|
QqbotDashboardController,
|
||||||
QqbotMessageController,
|
QqbotMessageController,
|
||||||
|
QqbotMessagePushController,
|
||||||
QqbotPermissionController,
|
QqbotPermissionController,
|
||||||
QqbotRuleController,
|
QqbotRuleController,
|
||||||
QqbotSendController,
|
QqbotSendController,
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<string, string[]> = {
|
||||||
|
'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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user