From a06cf0ee3f4571d83d8e3aa98c245eeb45388aee Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 30 May 2026 20:47:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5QQBot=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + package.json | 5 +- pnpm-lock.yaml | 248 +++++++++++++- sql/qqbot-init.sql | 189 +++++++++++ src/app.module.ts | 2 + src/qqbot/account/qqbot-account.controller.ts | 72 ++++ src/qqbot/account/qqbot-account.dto.ts | 44 +++ src/qqbot/account/qqbot-account.entity.ts | 83 +++++ src/qqbot/account/qqbot-account.service.ts | 179 ++++++++++ .../connection/qqbot-reverse-ws.service.ts | 321 ++++++++++++++++++ .../dashboard/qqbot-dashboard.controller.ts | 18 + .../dashboard/qqbot-dashboard.service.ts | 64 ++++ src/qqbot/dedupe/qqbot-dedupe.entity.ts | 37 ++ src/qqbot/dedupe/qqbot-dedupe.service.ts | 32 ++ src/qqbot/event/qqbot-event-normalizer.ts | 66 ++++ src/qqbot/event/qqbot-event.service.ts | 51 +++ .../message/qqbot-conversation.entity.ts | 66 ++++ src/qqbot/message/qqbot-message.controller.ts | 28 ++ src/qqbot/message/qqbot-message.dto.ts | 27 ++ src/qqbot/message/qqbot-message.entity.ts | 73 ++++ src/qqbot/message/qqbot-message.service.ts | 175 ++++++++++ src/qqbot/mqtt/qqbot-bus.service.ts | 82 +++++ .../permission/qqbot-allowlist.entity.ts | 45 +++ .../permission/qqbot-blocklist.entity.ts | 45 +++ .../permission/qqbot-permission.controller.ts | 82 +++++ src/qqbot/permission/qqbot-permission.dto.ts | 43 +++ .../permission/qqbot-permission.service.ts | 140 ++++++++ src/qqbot/qqbot.constants.ts | 13 + src/qqbot/qqbot.module.ts | 71 ++++ src/qqbot/qqbot.types.ts | 52 +++ src/qqbot/qqbot.utils.ts | 41 +++ src/qqbot/rule/qqbot-rule-engine.service.ts | 41 +++ src/qqbot/rule/qqbot-rule.controller.ts | 66 ++++ src/qqbot/rule/qqbot-rule.dto.ts | 53 +++ src/qqbot/rule/qqbot-rule.entity.ts | 65 ++++ src/qqbot/rule/qqbot-rule.service.ts | 153 +++++++++ src/qqbot/send/qqbot-rate-limit.service.ts | 42 +++ src/qqbot/send/qqbot-send-log.entity.ts | 62 ++++ src/qqbot/send/qqbot-send.controller.ts | 46 +++ src/qqbot/send/qqbot-send.dto.ts | 44 +++ src/qqbot/send/qqbot-send.service.ts | 178 ++++++++++ 41 files changed, 3154 insertions(+), 3 deletions(-) create mode 100644 sql/qqbot-init.sql create mode 100644 src/qqbot/account/qqbot-account.controller.ts create mode 100644 src/qqbot/account/qqbot-account.dto.ts create mode 100644 src/qqbot/account/qqbot-account.entity.ts create mode 100644 src/qqbot/account/qqbot-account.service.ts create mode 100644 src/qqbot/connection/qqbot-reverse-ws.service.ts create mode 100644 src/qqbot/dashboard/qqbot-dashboard.controller.ts create mode 100644 src/qqbot/dashboard/qqbot-dashboard.service.ts create mode 100644 src/qqbot/dedupe/qqbot-dedupe.entity.ts create mode 100644 src/qqbot/dedupe/qqbot-dedupe.service.ts create mode 100644 src/qqbot/event/qqbot-event-normalizer.ts create mode 100644 src/qqbot/event/qqbot-event.service.ts create mode 100644 src/qqbot/message/qqbot-conversation.entity.ts create mode 100644 src/qqbot/message/qqbot-message.controller.ts create mode 100644 src/qqbot/message/qqbot-message.dto.ts create mode 100644 src/qqbot/message/qqbot-message.entity.ts create mode 100644 src/qqbot/message/qqbot-message.service.ts create mode 100644 src/qqbot/mqtt/qqbot-bus.service.ts create mode 100644 src/qqbot/permission/qqbot-allowlist.entity.ts create mode 100644 src/qqbot/permission/qqbot-blocklist.entity.ts create mode 100644 src/qqbot/permission/qqbot-permission.controller.ts create mode 100644 src/qqbot/permission/qqbot-permission.dto.ts create mode 100644 src/qqbot/permission/qqbot-permission.service.ts create mode 100644 src/qqbot/qqbot.constants.ts create mode 100644 src/qqbot/qqbot.module.ts create mode 100644 src/qqbot/qqbot.types.ts create mode 100644 src/qqbot/qqbot.utils.ts create mode 100644 src/qqbot/rule/qqbot-rule-engine.service.ts create mode 100644 src/qqbot/rule/qqbot-rule.controller.ts create mode 100644 src/qqbot/rule/qqbot-rule.dto.ts create mode 100644 src/qqbot/rule/qqbot-rule.entity.ts create mode 100644 src/qqbot/rule/qqbot-rule.service.ts create mode 100644 src/qqbot/send/qqbot-rate-limit.service.ts create mode 100644 src/qqbot/send/qqbot-send-log.entity.ts create mode 100644 src/qqbot/send/qqbot-send.controller.ts create mode 100644 src/qqbot/send/qqbot-send.dto.ts create mode 100644 src/qqbot/send/qqbot-send.service.ts diff --git a/.env.example b/.env.example index 62bff5d..84fbadf 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,16 @@ ADMIN_TOKEN_SECRET=change-me ADMIN_COOKIE_SECURE=false SNOWFLAKE_WORKER_ID=1 SNOWFLAKE_DATACENTER_ID=1 + +QQBOT_ENABLED=false +QQBOT_EVENT_BUS=mqtt +QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse +QQBOT_REVERSE_WS_TOKEN= +QQBOT_REQUIRE_ALLOWLIST=true +QQBOT_API_TIMEOUT_MS=10000 +QQBOT_SEND_RATE_PER_SECOND=1 + +MQTT_URL=mqtt://127.0.0.1:1883 +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_CLIENT_ID=kt-template-online-api-qqbot diff --git a/package.json b/package.json index 7804391..d30a832 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,15 @@ "cross-env": "^7.0.3", "express": "4.18.2", "lodash": "^4.17.21", + "mqtt": "^5.15.1", "mysql2": "^3.22.3", "nestjs-knife4j-plus": "^1.0.8", "nestjs-minio-client": "^2.2.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.2", "svg-captcha": "^1.4.0", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "ws": "^8.21.0" }, "devDependencies": { "@nestjs/cli": "^9.5.0", @@ -52,6 +54,7 @@ "@types/lodash": "^4.17.24", "@types/node": "18.11.18", "@types/supertest": "^2.0.16", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "eslint": "^8.57.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29a7f7b..5b3e4f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + mqtt: + specifier: ^5.15.1 + version: 5.15.1 mysql2: specifier: ^3.22.3 version: 3.22.3(@types/node@18.11.18) @@ -56,6 +59,9 @@ importers: typeorm: specifier: ^0.3.28 version: 0.3.28(mssql@9.3.2(@azure/core-client@1.10.1))(mysql2@3.22.3(@types/node@18.11.18))(ts-node@10.9.2(@types/node@18.11.18)(typescript@4.9.5)) + ws: + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@nestjs/cli': specifier: ^9.5.0 @@ -81,6 +87,9 @@ importers: '@types/supertest': specifier: ^2.0.16 version: 2.0.16 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@typescript-eslint/eslint-plugin': specifier: ^5.62.0 version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) @@ -384,6 +393,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -789,6 +802,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -810,6 +826,9 @@ packages: '@types/supertest@2.0.16': resolution: {integrity: sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -935,6 +954,10 @@ packages: '@zxing/text-encoding@0.9.0': resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1124,6 +1147,9 @@ packages: bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + block-stream2@2.1.0: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} @@ -1145,6 +1171,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + broker-factory@3.1.14: + resolution: {integrity: sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==} + browser-or-node@2.1.1: resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} @@ -1289,6 +1318,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commist@3.2.0: + resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -1299,6 +1331,10 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + consola@2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} @@ -1630,6 +1666,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1677,6 +1717,10 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-unique-numbers@9.0.27: + resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==} + engines: {node: '>=18.2.0'} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -1893,6 +1937,9 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1976,6 +2023,10 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2307,6 +2358,9 @@ packages: js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-sdsl@4.3.0: + resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2549,6 +2603,14 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mqtt-packet@9.0.2: + resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==} + + mqtt@5.15.1: + resolution: {integrity: sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==} + engines: {node: '>=16.0.0'} + hasBin: true + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2642,6 +2704,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + number-allocator@1.0.14: + resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2807,6 +2872,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -2862,6 +2931,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -3069,6 +3142,14 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -3091,6 +3172,10 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -3588,6 +3673,18 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + worker-factory@7.0.49: + resolution: {integrity: sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==} + + worker-timers-broker@8.0.16: + resolution: {integrity: sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==} + + worker-timers-worker@9.0.14: + resolution: {integrity: sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==} + + worker-timers@8.0.31: + resolution: {integrity: sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3603,6 +3700,18 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -4019,6 +4128,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -4570,6 +4681,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/readable-stream@4.0.23': + dependencies: + '@types/node': 18.11.18 + '@types/semver@7.7.1': {} '@types/send@0.17.6': @@ -4600,6 +4715,10 @@ snapshots: dependencies: '@types/superagent': 8.1.9 + '@types/ws@8.18.1': + dependencies: + '@types/node': 18.11.18 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4784,6 +4903,10 @@ snapshots: '@zxing/text-encoding@0.9.0': optional: true + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4993,6 +5116,13 @@ snapshots: readable-stream: 3.6.2 optional: true + bl@6.1.6: + dependencies: + '@types/readable-stream': 4.0.23 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.7.0 + block-stream2@2.1.0: dependencies: readable-stream: 3.6.2 @@ -5044,6 +5174,13 @@ snapshots: dependencies: fill-range: 7.1.1 + broker-factory@3.1.14: + dependencies: + '@babel/runtime': 7.29.7 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + worker-factory: 7.0.49 + browser-or-node@2.1.1: {} browserslist@4.28.2: @@ -5180,6 +5317,8 @@ snapshots: commander@4.1.1: {} + commist@3.2.0: {} + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -5191,6 +5330,13 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + consola@2.15.3: {} content-disposition@0.5.4: @@ -5578,6 +5724,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + events@3.3.0: {} execa@4.1.0: @@ -5674,6 +5822,11 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-unique-numbers@9.0.27: + dependencies: + '@babel/runtime': 7.29.7 + tslib: 2.8.1 + fast-uri@3.1.2: {} fast-xml-parser@4.5.6: @@ -5932,6 +6085,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -6045,6 +6200,8 @@ snapshots: interpret@1.4.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.4.0: {} @@ -6580,6 +6737,8 @@ snapshots: js-md4@0.3.2: optional: true + js-sdsl@4.3.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -6806,6 +6965,37 @@ snapshots: dependencies: minimist: 1.2.8 + mqtt-packet@9.0.2: + dependencies: + bl: 6.1.6 + debug: 4.4.3 + process-nextick-args: 2.0.1 + transitivePeerDependencies: + - supports-color + + mqtt@5.15.1: + dependencies: + '@types/readable-stream': 4.0.23 + '@types/ws': 8.18.1 + commist: 3.2.0 + concat-stream: 2.0.0 + debug: 4.4.3 + help-me: 5.0.0 + lru-cache: 10.4.3 + minimist: 1.2.8 + mqtt-packet: 9.0.2 + number-allocator: 1.0.14 + readable-stream: 4.7.0 + rfdc: 1.4.1 + socks: 2.8.9 + split2: 4.2.0 + worker-timers: 8.0.31 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + ms@2.0.0: {} ms@2.1.3: {} @@ -6895,6 +7085,13 @@ snapshots: dependencies: path-key: 3.1.1 + number-allocator@1.0.14: + dependencies: + debug: 4.4.3 + js-sdsl: 4.3.0 + transitivePeerDependencies: + - supports-color + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7054,6 +7251,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -7124,6 +7323,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -7188,8 +7395,7 @@ snapshots: reusify@1.1.0: {} - rfdc@1.4.1: - optional: true + rfdc@1.4.1: {} rimraf@3.0.2: dependencies: @@ -7369,6 +7575,13 @@ snapshots: slash@3.0.0: {} + smart-buffer@4.2.0: {} + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -7387,6 +7600,8 @@ snapshots: split-on-first@1.1.0: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: @@ -7924,6 +8139,33 @@ snapshots: word-wrap@1.2.5: {} + worker-factory@7.0.49: + dependencies: + '@babel/runtime': 7.29.7 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + + worker-timers-broker@8.0.16: + dependencies: + '@babel/runtime': 7.29.7 + broker-factory: 3.1.14 + fast-unique-numbers: 9.0.27 + tslib: 2.8.1 + worker-timers-worker: 9.0.14 + + worker-timers-worker@9.0.14: + dependencies: + '@babel/runtime': 7.29.7 + tslib: 2.8.1 + worker-factory: 7.0.49 + + worker-timers@8.0.31: + dependencies: + '@babel/runtime': 7.29.7 + tslib: 2.8.1 + worker-timers-broker: 8.0.16 + worker-timers-worker: 9.0.14 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -7943,6 +8185,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.21.0: {} + xml2js@0.5.0: dependencies: sax: 1.6.0 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql new file mode 100644 index 0000000..ec17e61 --- /dev/null +++ b/sql/qqbot-init.sql @@ -0,0 +1,189 @@ +-- QQBot 初始化 SQL +-- 用途:补齐 QQBot 表结构、后台菜单和默认角色授权。 +-- 说明:本文件不清空任何已有角色菜单;请按目标环境手动导入。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `qqbot_account` ( + `id` bigint NOT NULL, + `connection_mode` varchar(32) NOT NULL DEFAULT 'reverse-ws', + `self_id` varchar(64) NOT NULL, + `name` varchar(120) NOT NULL DEFAULT '', + `access_token` varchar(255) DEFAULT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `connect_status` varchar(32) NOT NULL DEFAULT 'offline', + `client_role` varchar(32) DEFAULT NULL, + `last_connected_at` datetime DEFAULT NULL, + `last_heartbeat_at` datetime DEFAULT NULL, + `last_error` varchar(500) DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_qqbot_account_self_id` (`self_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_rule` ( + `id` bigint NOT NULL, + `name` varchar(120) NOT NULL DEFAULT '', + `match_type` varchar(32) NOT NULL DEFAULT 'keyword', + `keyword` varchar(500) NOT NULL, + `target_type` varchar(32) NOT NULL DEFAULT 'all', + `reply_content` text NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `priority` int NOT NULL DEFAULT 0, + `cooldown_ms` int NOT NULL DEFAULT 1500, + `last_hit_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_rule_target` (`target_type`), + KEY `idx_qqbot_rule_enabled` (`enabled`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_conversation` ( + `id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL, + `target_type` varchar(32) NOT NULL, + `target_id` varchar(64) NOT NULL, + `target_name` varchar(120) NOT NULL DEFAULT '', + `last_message_id` varchar(64) DEFAULT NULL, + `last_message_text` text NOT NULL, + `last_message_time` datetime DEFAULT NULL, + `message_count` int NOT NULL DEFAULT 0, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_conversation_target` (`self_id`, `target_type`, `target_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_message` ( + `id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL, + `message_id` varchar(64) DEFAULT NULL, + `conversation_id` varchar(64) DEFAULT NULL, + `direction` varchar(32) NOT NULL DEFAULT 'inbound', + `message_type` varchar(32) NOT NULL, + `target_id` varchar(64) NOT NULL, + `group_id` varchar(64) DEFAULT NULL, + `user_id` varchar(64) NOT NULL, + `sender_nickname` varchar(120) NOT NULL DEFAULT '', + `raw_message` text NOT NULL, + `message_text` text NOT NULL, + `raw_event` longtext DEFAULT NULL, + `event_time` datetime NOT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_message_self_message` (`self_id`, `message_id`), + KEY `idx_qqbot_message_target` (`self_id`, `message_type`, `target_id`), + KEY `idx_qqbot_message_event_time` (`event_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_send_log` ( + `id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL, + `target_type` varchar(32) NOT NULL, + `target_id` varchar(64) NOT NULL, + `action` varchar(64) NOT NULL, + `message_text` text NOT NULL, + `params` longtext DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `echo` varchar(80) DEFAULT NULL, + `message_id` varchar(64) DEFAULT NULL, + `error_message` varchar(500) DEFAULT NULL, + `response` longtext DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_send_log_target` (`self_id`, `target_type`, `target_id`), + KEY `idx_qqbot_send_log_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_allowlist` ( + `id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL DEFAULT '', + `target_type` varchar(32) NOT NULL DEFAULT 'all', + `target_id` varchar(64) NOT NULL DEFAULT '', + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_allowlist_target` (`self_id`, `target_type`, `target_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_blocklist` ( + `id` bigint NOT NULL, + `self_id` varchar(64) NOT NULL DEFAULT '', + `target_type` varchar(32) NOT NULL DEFAULT 'all', + `target_id` varchar(64) NOT NULL DEFAULT '', + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_blocklist_target` (`self_id`, `target_type`, `target_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_dedupe` ( + `id` bigint NOT NULL, + `event_key` varchar(255) NOT NULL, + `expire_at` datetime DEFAULT NULL, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) +VALUES + (2041700000000100400, 0, 'QqBot', '/qqbot', NULL, '/qqbot/dashboard', NULL, 'catalog', '{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}', 1, 110), + (2041700000000100401, 2041700000000100400, 'QqBotDashboard', '/qqbot/dashboard', '/qqbot/dashboard/list', NULL, 'QqBot:Dashboard:List', 'menu', '{"icon":"lucide:gauge","title":"工作台"}', 1, 0), + (2041700000000100402, 2041700000000100400, 'QqBotAccount', '/qqbot/account', '/qqbot/account/list', NULL, 'QqBot:Account:List', 'menu', '{"icon":"lucide:radio-receiver","title":"账号连接"}', 1, 1), + (2041700000000120401, 2041700000000100402, 'QqBotAccountCreate', NULL, NULL, NULL, 'QqBot:Account:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0), + (2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2), + (2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120413, 2041700000000100403, 'QqBotRuleDelete', NULL, NULL, NULL, 'QqBot:Rule:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000120414, 2041700000000100403, 'QqBotRuleToggle', NULL, NULL, NULL, 'QqBot:Rule:Toggle', 'button', '{"title":"启停"}', 1, 0), + (2041700000000100404, 2041700000000100400, 'QqBotConversation', '/qqbot/conversation', '/qqbot/conversation/list', NULL, 'QqBot:Conversation:List', 'menu', '{"icon":"lucide:messages-square","title":"会话管理"}', 1, 3), + (2041700000000100405, 2041700000000100400, 'QqBotMessage', '/qqbot/message', '/qqbot/message/list', NULL, 'QqBot:Message:List', 'menu', '{"icon":"lucide:message-square-text","title":"消息日志"}', 1, 4), + (2041700000000100406, 2041700000000100400, 'QqBotSendLog', '/qqbot/sendLog', '/qqbot/sendLog/list', NULL, 'QqBot:SendLog:List', 'menu', '{"icon":"lucide:send","title":"发送日志"}', 1, 5), + (2041700000000120421, 2041700000000100406, 'QqBotSendPrivate', NULL, NULL, NULL, 'QqBot:Send:Private', 'button', '{"title":"发送私聊"}', 1, 0), + (2041700000000120422, 2041700000000100406, 'QqBotSendGroup', NULL, NULL, NULL, 'QqBot:Send:Group', 'button', '{"title":"发送群聊"}', 1, 0), + (2041700000000100407, 2041700000000100400, 'QqBotPermission', '/qqbot/permission', '/qqbot/permission/list', NULL, 'QqBot:Permission:List', 'menu', '{"icon":"lucide:shield-check","title":"权限名单"}', 1, 6), + (2041700000000120431, 2041700000000100407, 'QqBotPermissionCreate', NULL, NULL, NULL, 'QqBot:Permission:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120432, 2041700000000100407, 'QqBotPermissionEdit', NULL, NULL, NULL, 'QqBot:Permission:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120433, 2041700000000100407, 'QqBotPermissionDelete', NULL, NULL, NULL, 'QqBot:Permission:Delete', 'button', '{"title":"common.delete"}', 1, 0) +ON DUPLICATE KEY UPDATE + `pid` = VALUES(`pid`), + `path` = VALUES(`path`), + `component` = VALUES(`component`), + `redirect` = VALUES(`redirect`), + `auth_code` = VALUES(`auth_code`), + `type` = VALUES(`type`), + `meta` = VALUES(`meta`), + `status` = VALUES(`status`), + `sort` = VALUES(`sort`), + `is_deleted` = 0; + +INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) +SELECT role.`id`, menu.`id` +FROM `admin_role` role +JOIN `admin_menu` menu ON menu.`name` LIKE 'QqBot%' +WHERE role.`role_code` IN ('super', 'admin') + AND role.`is_deleted` = 0 + AND menu.`is_deleted` = 0; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/app.module.ts b/src/app.module.ts index dc5ae2b..f554084 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { MinioClientModule } from './minio/minio.module'; import { ApiExceptionFilter, SaveBodyInterceptor } from './common'; import { AdminModule } from './admin/admin.module'; import { WordpressModule } from './wordpress/wordpress.module'; +import { QqbotModule } from './qqbot/qqbot.module'; @Module({ imports: [ @@ -50,6 +51,7 @@ import { WordpressModule } from './wordpress/wordpress.module'; MinioClientModule, AdminModule, WordpressModule, + QqbotModule, ], providers: [ AppService, diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts new file mode 100644 index 0000000..7dd7067 --- /dev/null +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { + QqbotAccountBodyDto, + QqbotAccountQueryDto, + QqbotAccountUpdateDto, +} from './qqbot-account.dto'; +import { QqbotAccountService } from './qqbot-account.service'; +import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; + +@ApiTags('qqbot-account') +@Controller('qqbot/account') +@UseGuards(JwtAuthGuard) +export class QqbotAccountController { + constructor( + private readonly accountService: QqbotAccountService, + private readonly reverseWsService: QqbotReverseWsService, + ) {} + + @Get('list') + @ApiOperation({ summary: 'QQBot 账号分页' }) + async list(@Query() query: QqbotAccountQueryDto) { + return vbenSuccess(await this.accountService.page(query)); + } + + @Get('enabled') + @ApiOperation({ summary: 'QQBot 可用账号' }) + async enabled() { + return vbenSuccess(await this.accountService.allEnabled()); + } + + @Post('save') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '新增 QQBot 账号' }) + async save(@Body() body: QqbotAccountBodyDto) { + return vbenSuccess(await this.accountService.save(body)); + } + + @Post('update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '编辑 QQBot 账号' }) + async update(@Body() body: QqbotAccountUpdateDto) { + return vbenSuccess(await this.accountService.update(body)); + } + + @Post('delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除 QQBot 账号' }) + @ApiQuery({ name: 'id', type: String }) + async delete(@Query('id') id: string) { + return vbenSuccess(await this.accountService.remove(id)); + } + + @Post('kick') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '断开 QQBot 反向 WS 会话' }) + @ApiQuery({ name: 'selfId', type: String }) + async kick(@Query('selfId') selfId: string) { + return vbenSuccess(await this.reverseWsService.kick(selfId)); + } +} diff --git a/src/qqbot/account/qqbot-account.dto.ts b/src/qqbot/account/qqbot-account.dto.ts new file mode 100644 index 0000000..8256e92 --- /dev/null +++ b/src/qqbot/account/qqbot-account.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import type { QqbotConnectionMode } from '../qqbot.types'; + +export class QqbotAccountBodyDto { + @ApiPropertyOptional({ default: 'reverse-ws' }) + connectionMode?: QqbotConnectionMode; + + @ApiProperty({ example: '10000' }) + selfId: string; + + @ApiPropertyOptional({ example: '主账号' }) + name?: string; + + @ApiPropertyOptional({ description: 'OneBot 反向 WS token' }) + accessToken?: string; + + @ApiPropertyOptional({ default: true }) + enabled?: boolean; + + @ApiPropertyOptional() + remark?: string; +} + +export class QqbotAccountUpdateDto extends PartialType(QqbotAccountBodyDto) { + @ApiProperty() + id: string; +} + +export class QqbotAccountQueryDto { + @ApiPropertyOptional({ default: 1 }) + pageNo?: number; + + @ApiPropertyOptional({ default: 10 }) + pageSize?: number; + + @ApiPropertyOptional() + selfId?: string; + + @ApiPropertyOptional() + name?: string; + + @ApiPropertyOptional() + connectStatus?: string; +} diff --git a/src/qqbot/account/qqbot-account.entity.ts b/src/qqbot/account/qqbot-account.entity.ts new file mode 100644 index 0000000..45d3d33 --- /dev/null +++ b/src/qqbot/account/qqbot-account.entity.ts @@ -0,0 +1,83 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { + QqbotConnectionMode, + QqbotConnectionRole, + QqbotConnectionStatus, +} from '../qqbot.types'; + +@Entity('qqbot_account') +export class QqbotAccount { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ default: 'reverse-ws', length: 32, name: 'connection_mode' }) + connectionMode: QqbotConnectionMode; + + @Column({ length: 64, name: 'self_id', unique: true }) + selfId: string; + + @Column({ default: '', length: 120 }) + name: string; + + @Column({ + default: null, + length: 255, + name: 'access_token', + nullable: true, + select: false, + }) + accessToken: null | string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: 'offline', length: 32, name: 'connect_status' }) + connectStatus: QqbotConnectionStatus; + + @Column({ default: null, length: 32, name: 'client_role', nullable: true }) + clientRole: null | QqbotConnectionRole; + + @Column({ + default: null, + name: 'last_connected_at', + nullable: true, + type: 'datetime', + }) + lastConnectedAt: Date | null; + + @Column({ + default: null, + name: 'last_heartbeat_at', + nullable: true, + type: 'datetime', + }) + lastHeartbeatAt: Date | null; + + @Column({ default: null, length: 500, name: 'last_error', nullable: true }) + lastError: null | string; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts new file mode 100644 index 0000000..f3946d9 --- /dev/null +++ b/src/qqbot/account/qqbot-account.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { QqbotAccount } from './qqbot-account.entity'; +import type { + QqbotAccountBodyDto, + QqbotAccountQueryDto, + QqbotAccountUpdateDto, +} from './qqbot-account.dto'; +import type { QqbotConnectionRole } from '../qqbot.types'; +import { getPageParams, normalizeNullableString } from '../qqbot.utils'; + +@Injectable() +export class QqbotAccountService { + constructor( + @InjectRepository(QqbotAccount) + private readonly accountRepository: Repository, + ) {} + + async page(query: QqbotAccountQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.accountRepository + .createQueryBuilder('account') + .where('account.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.selfId) { + builder.andWhere('account.selfId LIKE :selfId', { + selfId: `%${query.selfId}%`, + }); + } + if (query.name) { + builder.andWhere('account.name LIKE :name', { name: `%${query.name}%` }); + } + if (query.connectStatus) { + builder.andWhere('account.connectStatus = :connectStatus', { + connectStatus: query.connectStatus, + }); + } + + const [list, total] = await builder + .orderBy('account.createTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async allEnabled() { + return this.accountRepository.find({ + order: { + createTime: 'ASC', + }, + where: { + enabled: true, + isDeleted: false, + }, + }); + } + + async getDefaultAccount(selfId?: string) { + if (selfId) { + const account = await this.accountRepository.findOne({ + where: { enabled: true, isDeleted: false, selfId }, + }); + if (account) return account; + } + + return this.accountRepository.findOne({ + order: { + createTime: 'ASC', + }, + where: { + enabled: true, + isDeleted: false, + }, + }); + } + + async findEnabledBySelfIdWithToken(selfId: string) { + return this.accountRepository + .createQueryBuilder('account') + .addSelect('account.accessToken') + .where('account.selfId = :selfId', { selfId }) + .andWhere('account.enabled = :enabled', { enabled: true }) + .andWhere('account.isDeleted = :isDeleted', { isDeleted: false }) + .getOne(); + } + + async save(body: QqbotAccountBodyDto) { + await this.assertSelfIdAvailable(body.selfId); + const account = this.accountRepository.create(this.normalizeBody(body)); + const saved = await this.accountRepository.save(account); + return saved.id; + } + + async update(body: QqbotAccountUpdateDto) { + if (body.selfId) { + await this.assertSelfIdAvailable(body.selfId, body.id); + } + const payload = this.normalizeBody(body); + delete (payload as any).id; + if (!body.accessToken) { + delete payload.accessToken; + } + await this.accountRepository.update({ id: body.id }, payload); + return true; + } + + async remove(id: string) { + await this.accountRepository.update( + { id }, + { + connectStatus: 'offline', + isDeleted: true, + }, + ); + return true; + } + + async markOnline( + selfId: string, + clientRole: QqbotConnectionRole, + lastError: null | string = null, + ) { + await this.accountRepository.update( + { selfId }, + { + clientRole, + connectStatus: 'online', + lastConnectedAt: new Date(), + lastError, + }, + ); + } + + async markHeartbeat(selfId: string) { + await this.accountRepository.update( + { selfId }, + { + connectStatus: 'online', + lastHeartbeatAt: new Date(), + }, + ); + } + + async markOffline(selfId: string, lastError?: string) { + await this.accountRepository.update( + { selfId }, + { + connectStatus: 'offline', + lastError: lastError || null, + }, + ); + } + + private async assertSelfIdAvailable(selfId: string, id?: string) { + const exists = await this.accountRepository.findOne({ + where: { + isDeleted: false, + selfId, + }, + }); + if (exists && exists.id !== id) { + throwVbenError('QQBot 账号 selfId 已存在'); + } + } + + private normalizeBody(body: Partial) { + return { + accessToken: normalizeNullableString(body.accessToken), + connectionMode: body.connectionMode || 'reverse-ws', + enabled: body.enabled ?? true, + name: body.name || '', + remark: body.remark || '', + selfId: body.selfId, + } as Partial; + } +} diff --git a/src/qqbot/connection/qqbot-reverse-ws.service.ts b/src/qqbot/connection/qqbot-reverse-ws.service.ts new file mode 100644 index 0000000..02b1ec2 --- /dev/null +++ b/src/qqbot/connection/qqbot-reverse-ws.service.ts @@ -0,0 +1,321 @@ +import type { IncomingMessage } from 'http'; +import type { Socket } from 'net'; +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnModuleDestroy, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpAdapterHost, ModuleRef } from '@nestjs/core'; +import WebSocket = require('ws'); +import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants'; +import type { + QqbotConnectionRole, + QqbotOneBotActionResponse, + QqbotOneBotEvent, +} from '../qqbot.types'; +import { QqbotAccountService } from '../account/qqbot-account.service'; +import { QqbotEventService } from '../event/qqbot-event.service'; +import { QqbotBusService } from '../mqtt/qqbot-bus.service'; + +type PendingAction = { + reject: (reason: Error) => void; + resolve: (value: QqbotOneBotActionResponse) => void; + timer: NodeJS.Timeout; +}; + +@Injectable() +export class QqbotReverseWsService + implements OnApplicationBootstrap, OnModuleDestroy +{ + private readonly logger = new Logger(QqbotReverseWsService.name); + private readonly connections = new Map(); + private readonly pendingActions = new Map(); + private server: WebSocket.Server | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly httpAdapterHost: HttpAdapterHost, + private readonly moduleRef: ModuleRef, + private readonly accountService: QqbotAccountService, + private readonly busService: QqbotBusService, + ) {} + + onApplicationBootstrap() { + if (!this.isEnabled()) { + this.logger.log('QQBot runtime 未启用,跳过反向 WS 监听'); + return; + } + + const httpServer = this.httpAdapterHost.httpAdapter.getHttpServer(); + this.server = new WebSocket.Server({ noServer: true }); + httpServer.on( + 'upgrade', + (request: IncomingMessage, socket: Socket, head) => { + if (!this.isReversePath(request)) return; + this.server?.handleUpgrade(request, socket, head, (ws) => { + this.server?.emit('connection', ws, request); + }); + }, + ); + this.server.on('connection', (ws, request) => { + this.handleConnection(ws, request); + }); + this.logger.log(`QQBot 反向 WS 已挂载: ${this.getReversePath()}`); + } + + onModuleDestroy() { + this.pendingActions.forEach((pending) => { + clearTimeout(pending.timer); + pending.reject(new Error('QQBot runtime stopped')); + }); + this.pendingActions.clear(); + this.connections.forEach((ws) => ws.close()); + this.server?.close(); + } + + async sendAction( + selfId: string, + action: string, + params: Record, + ) { + const ws = this.getWritableConnection(selfId); + const echo = `${selfId}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + const payload = { + action, + echo, + params, + }; + + const responsePromise = new Promise( + (resolve, reject) => { + const timer = setTimeout(() => { + this.pendingActions.delete(echo); + reject(new Error('OneBot action timeout')); + }, this.getActionTimeout()); + this.pendingActions.set(echo, { reject, resolve, timer }); + }, + ); + ws.send(JSON.stringify(payload)); + return responsePromise; + } + + async kick(selfId: string) { + let count = 0; + [...this.connections.entries()].forEach(([key, ws]) => { + if (!key.startsWith(`${selfId}:`)) return; + count += 1; + ws.close(1000, 'Admin kick'); + this.connections.delete(key); + }); + if (count > 0) await this.accountService.markOffline(selfId); + return { count }; + } + + getRuntimeStatus() { + return { + enabled: this.isEnabled(), + path: this.getReversePath(), + sessions: [...this.connections.keys()], + }; + } + + private async handleConnection(ws: WebSocket, request: IncomingMessage) { + let activeSelfId = ''; + const queuedMessages: string[] = []; + ws.on('message', async (buffer) => { + const raw = buffer.toString(); + if (!activeSelfId) { + if (queuedMessages.length >= 50) { + ws.close(1008, 'too many early messages'); + return; + } + queuedMessages.push(raw); + return; + } + await this.consumeMessage(activeSelfId, raw); + }); + + const context = await this.authorize(request); + if (!context.ok) { + ws.close(1008, context.message); + return; + } + + const key = this.getConnectionKey(context.selfId, context.role); + this.connections.set(key, ws); + activeSelfId = context.selfId; + await this.accountService.markOnline(context.selfId, context.role); + await this.busService.publish(QQBOT_MQTT_TOPICS.status(context.selfId), { + role: context.role, + selfId: context.selfId, + status: 'online', + }); + + ws.on('close', async () => { + this.connections.delete(key); + await this.accountService.markOffline(context.selfId); + await this.busService.publish(QQBOT_MQTT_TOPICS.status(context.selfId), { + role: context.role, + selfId: context.selfId, + status: 'offline', + }); + }); + ws.on('error', async (err) => { + this.logger.warn(`QQBot WS 错误 ${context.selfId}: ${err.message}`); + await this.accountService.markOffline(context.selfId, err.message); + }); + + while (queuedMessages.length > 0) { + await this.consumeMessage(context.selfId, queuedMessages.shift() || ''); + } + } + + private async consumeMessage(selfId: string, raw: string) { + try { + await this.handleMessage(selfId, raw); + } catch (err) { + const message = err instanceof Error ? err.message : `${err}`; + this.logger.warn(`QQBot 处理 WS 消息失败 ${selfId}: ${message}`); + } + } + + private async handleMessage(selfId: string, raw: string) { + let payload: QqbotOneBotEvent; + try { + payload = JSON.parse(raw); + } catch { + this.logger.warn('QQBot 收到非 JSON WS 消息,已忽略'); + return; + } + + if (payload.echo && this.pendingActions.has(`${payload.echo}`)) { + await this.resolvePendingAction( + selfId, + payload as QqbotOneBotActionResponse, + ); + return; + } + + if ( + payload.post_type === 'meta_event' && + payload.meta_event_type === 'heartbeat' + ) { + await this.accountService.markHeartbeat(selfId); + } + const eventService = this.moduleRef.get(QqbotEventService, { + strict: false, + }); + await eventService.handleIncoming({ + ...payload, + self_id: payload.self_id || selfId, + }); + } + + private async resolvePendingAction( + selfId: string, + payload: QqbotOneBotActionResponse, + ) { + const echo = `${payload.echo}`; + const pending = this.pendingActions.get(echo); + if (!pending) return; + + clearTimeout(pending.timer); + this.pendingActions.delete(echo); + await this.busService.publish( + QQBOT_MQTT_TOPICS.response(selfId, echo), + payload, + ); + pending.resolve(payload); + } + + private async authorize(request: IncomingMessage) { + const url = new URL(request.url || '', `http://${request.headers.host}`); + const selfId = `${ + request.headers['x-self-id'] || url.searchParams.get('self_id') || '' + }`.trim(); + const role = this.normalizeRole( + `${ + request.headers['x-client-role'] || + url.searchParams.get('role') || + 'Universal' + }`, + ); + const token = this.readToken(request, url); + + if (!selfId) { + return { ok: false as const, message: 'missing self id' }; + } + + const account = await this.accountService.findEnabledBySelfIdWithToken( + selfId, + ); + if (!account) { + return { ok: false as const, message: 'unknown account' }; + } + + const expectedToken = + account.accessToken || + this.configService.get('QQBOT_REVERSE_WS_TOKEN') || + ''; + if (expectedToken && token !== expectedToken) { + return { ok: false as const, message: 'invalid token' }; + } + + return { ok: true as const, role, selfId }; + } + + private getWritableConnection(selfId: string) { + const universal = this.connections.get( + this.getConnectionKey(selfId, 'Universal'), + ); + const api = this.connections.get(this.getConnectionKey(selfId, 'API')); + const ws = api || universal; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error(`QQBot ${selfId} 未连接可用 API WS`); + } + return ws; + } + + private getConnectionKey(selfId: string, role: QqbotConnectionRole) { + return `${selfId}:${role}`; + } + + private getReversePath() { + return ( + this.configService.get('QQBOT_REVERSE_WS_PATH') || + QQBOT_REVERSE_WS_PATH + ); + } + + private getActionTimeout() { + return Number(this.configService.get('QQBOT_API_TIMEOUT_MS') || 10_000); + } + + private isEnabled() { + return `${this.configService.get('QQBOT_ENABLED') || 'false'}` === 'true'; + } + + private isReversePath(request: IncomingMessage) { + const url = new URL(request.url || '', `http://${request.headers.host}`); + return url.pathname === this.getReversePath(); + } + + private normalizeRole(role: string): QqbotConnectionRole { + if (role === 'API' || role === 'Event') return role; + return 'Universal'; + } + + private readToken(request: IncomingMessage, url: URL) { + const authorization = `${request.headers.authorization || ''}`; + if (authorization.startsWith('Bearer ')) return authorization.slice(7); + return ( + url.searchParams.get('token') || + url.searchParams.get('access_token') || + `${request.headers['x-onebot-token'] || ''}` + ); + } +} diff --git a/src/qqbot/dashboard/qqbot-dashboard.controller.ts b/src/qqbot/dashboard/qqbot-dashboard.controller.ts new file mode 100644 index 0000000..95290c4 --- /dev/null +++ b/src/qqbot/dashboard/qqbot-dashboard.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { QqbotDashboardService } from './qqbot-dashboard.service'; + +@ApiTags('qqbot-dashboard') +@Controller('qqbot/dashboard') +@UseGuards(JwtAuthGuard) +export class QqbotDashboardController { + constructor(private readonly dashboardService: QqbotDashboardService) {} + + @Get('summary') + @ApiOperation({ summary: 'QQBot 工作台汇总' }) + async summary() { + return vbenSuccess(await this.dashboardService.summary()); + } +} diff --git a/src/qqbot/dashboard/qqbot-dashboard.service.ts b/src/qqbot/dashboard/qqbot-dashboard.service.ts new file mode 100644 index 0000000..50bedd7 --- /dev/null +++ b/src/qqbot/dashboard/qqbot-dashboard.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QqbotAccount } from '../account/qqbot-account.entity'; +import { QqbotConversation } from '../message/qqbot-conversation.entity'; +import { QqbotMessage } from '../message/qqbot-message.entity'; +import { QqbotBusService } from '../mqtt/qqbot-bus.service'; +import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; +import { QqbotRule } from '../rule/qqbot-rule.entity'; +import { QqbotSendLog } from '../send/qqbot-send-log.entity'; + +@Injectable() +export class QqbotDashboardService { + constructor( + @InjectRepository(QqbotAccount) + private readonly accountRepository: Repository, + @InjectRepository(QqbotConversation) + private readonly conversationRepository: Repository, + @InjectRepository(QqbotMessage) + private readonly messageRepository: Repository, + @InjectRepository(QqbotRule) + private readonly ruleRepository: Repository, + @InjectRepository(QqbotSendLog) + private readonly sendLogRepository: Repository, + private readonly busService: QqbotBusService, + private readonly reverseWsService: QqbotReverseWsService, + ) {} + + async summary() { + const [ + accountTotal, + onlineTotal, + enabledRuleTotal, + conversationTotal, + messageTotal, + sendSuccessTotal, + sendFailedTotal, + ] = await Promise.all([ + this.accountRepository.count({ where: { isDeleted: false } }), + this.accountRepository.count({ + where: { connectStatus: 'online', isDeleted: false }, + }), + this.ruleRepository.count({ + where: { enabled: true, isDeleted: false }, + }), + this.conversationRepository.count({ where: { isDeleted: false } }), + this.messageRepository.count(), + this.sendLogRepository.count({ where: { status: 'success' } }), + this.sendLogRepository.count({ where: { status: 'failed' } }), + ]); + + return { + accountTotal, + bus: this.busService.getStatus(), + conversationTotal, + enabledRuleTotal, + messageTotal, + onlineTotal, + runtime: this.reverseWsService.getRuntimeStatus(), + sendFailedTotal, + sendSuccessTotal, + }; + } +} diff --git a/src/qqbot/dedupe/qqbot-dedupe.entity.ts b/src/qqbot/dedupe/qqbot-dedupe.entity.ts new file mode 100644 index 0000000..47723e0 --- /dev/null +++ b/src/qqbot/dedupe/qqbot-dedupe.entity.ts @@ -0,0 +1,37 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; + +@Entity('qqbot_dedupe') +export class QqbotDedupe { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 255, name: 'event_key', unique: true }) + eventKey: string; + + @Column({ + default: null, + name: 'expire_at', + nullable: true, + type: 'datetime', + }) + expireAt: Date | null; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/dedupe/qqbot-dedupe.service.ts b/src/qqbot/dedupe/qqbot-dedupe.service.ts new file mode 100644 index 0000000..6f50222 --- /dev/null +++ b/src/qqbot/dedupe/qqbot-dedupe.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QqbotDedupe } from './qqbot-dedupe.entity'; + +@Injectable() +export class QqbotDedupeService { + constructor( + @InjectRepository(QqbotDedupe) + private readonly dedupeRepository: Repository, + ) {} + + async claim(eventKey: string) { + const exists = await this.dedupeRepository.findOne({ + where: { + eventKey, + }, + }); + if (exists) return false; + + try { + await this.dedupeRepository.save( + this.dedupeRepository.create({ + eventKey, + }), + ); + return true; + } catch { + return false; + } + } +} diff --git a/src/qqbot/event/qqbot-event-normalizer.ts b/src/qqbot/event/qqbot-event-normalizer.ts new file mode 100644 index 0000000..3c6ce42 --- /dev/null +++ b/src/qqbot/event/qqbot-event-normalizer.ts @@ -0,0 +1,66 @@ +import type { + QqbotMessageType, + QqbotNormalizedMessage, + QqbotOneBotEvent, +} from '../qqbot.types'; +import { toStringId } from '../qqbot.utils'; + +export function isOneBotMessageEvent( + payload: QqbotOneBotEvent, +): payload is QqbotOneBotEvent & { message_type: QqbotMessageType } { + return payload?.post_type === 'message' && !!payload.message_type; +} + +export function normalizeOneBotMessage( + payload: QqbotOneBotEvent, +): QqbotNormalizedMessage { + const messageType = payload.message_type as QqbotMessageType; + const groupId = toStringId(payload.group_id) || undefined; + const userId = toStringId(payload.user_id); + const targetId = messageType === 'group' ? groupId || '' : userId; + const messageText = extractMessageText(payload); + + return { + eventTime: payload.time + ? new Date(Number(payload.time) * 1000) + : new Date(), + groupId, + messageId: + toStringId(payload.message_id) || + `${payload.time || Date.now()}-${targetId}-${userId}`, + messageText, + messageType, + rawEvent: payload, + rawMessage: payload.raw_message || messageText, + selfId: toStringId(payload.self_id), + senderNickname: + payload.sender?.card || + payload.sender?.nickname || + payload.sender?.user_id || + '', + targetId, + userId, + }; +} + +export function buildDedupeKey(message: QqbotNormalizedMessage) { + return [ + message.selfId, + message.messageType, + message.targetId, + message.userId, + message.messageId, + ].join(':'); +} + +function extractMessageText(payload: QqbotOneBotEvent) { + if (payload.raw_message) return payload.raw_message; + if (typeof payload.message === 'string') return payload.message; + if (!Array.isArray(payload.message)) return ''; + + return payload.message + .filter((segment) => segment?.type === 'text') + .map((segment) => segment?.data?.text || '') + .join('') + .trim(); +} diff --git a/src/qqbot/event/qqbot-event.service.ts b/src/qqbot/event/qqbot-event.service.ts new file mode 100644 index 0000000..3717672 --- /dev/null +++ b/src/qqbot/event/qqbot-event.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; +import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service'; +import { QqbotMessageService } from '../message/qqbot-message.service'; +import { QqbotBusService } from '../mqtt/qqbot-bus.service'; +import type { QqbotOneBotEvent } from '../qqbot.types'; +import { + buildDedupeKey, + isOneBotMessageEvent, + normalizeOneBotMessage, +} from './qqbot-event-normalizer'; +import { QqbotRuleEngineService } from '../rule/qqbot-rule-engine.service'; + +@Injectable() +export class QqbotEventService { + private readonly logger = new Logger(QqbotEventService.name); + + constructor( + private readonly busService: QqbotBusService, + private readonly dedupeService: QqbotDedupeService, + private readonly messageService: QqbotMessageService, + private readonly ruleEngineService: QqbotRuleEngineService, + ) {} + + async handleIncoming(payload: QqbotOneBotEvent) { + const selfId = `${payload.self_id || ''}`; + if (selfId) { + await this.busService.publish( + QQBOT_MQTT_TOPICS.eventRaw(selfId), + payload, + ); + } + + if (!isOneBotMessageEvent(payload)) return; + const message = normalizeOneBotMessage(payload); + if (!message.selfId || !message.targetId || !message.userId) { + this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略'); + return; + } + + const claimed = await this.dedupeService.claim(buildDedupeKey(message)); + if (!claimed) return; + + await this.messageService.saveIncoming(message); + await this.busService.publish( + QQBOT_MQTT_TOPICS.eventMessage(message.selfId), + message, + ); + await this.ruleEngineService.handleMessage(message); + } +} diff --git a/src/qqbot/message/qqbot-conversation.entity.ts b/src/qqbot/message/qqbot-conversation.entity.ts new file mode 100644 index 0000000..b4745c0 --- /dev/null +++ b/src/qqbot/message/qqbot-conversation.entity.ts @@ -0,0 +1,66 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotMessageType } from '../qqbot.types'; + +@Entity('qqbot_conversation') +@Index('idx_qqbot_conversation_target', ['selfId', 'targetType', 'targetId']) +export class QqbotConversation { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 64, name: 'self_id' }) + selfId: string; + + @Column({ length: 32, name: 'target_type' }) + targetType: QqbotMessageType; + + @Column({ length: 64, name: 'target_id' }) + targetId: string; + + @Column({ default: '', length: 120, name: 'target_name' }) + targetName: string; + + @Column({ + default: null, + length: 64, + name: 'last_message_id', + nullable: true, + }) + lastMessageId: null | string; + + @Column({ name: 'last_message_text', type: 'text' }) + lastMessageText: string; + + @Column({ + default: null, + name: 'last_message_time', + nullable: true, + type: 'datetime', + }) + lastMessageTime: Date | null; + + @Column({ default: 0, name: 'message_count' }) + messageCount: number; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/message/qqbot-message.controller.ts b/src/qqbot/message/qqbot-message.controller.ts new file mode 100644 index 0000000..01adfd9 --- /dev/null +++ b/src/qqbot/message/qqbot-message.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { + QqbotConversationQueryDto, + QqbotMessageQueryDto, +} from './qqbot-message.dto'; +import { QqbotMessageService } from './qqbot-message.service'; + +@ApiTags('qqbot-message') +@Controller('qqbot') +@UseGuards(JwtAuthGuard) +export class QqbotMessageController { + constructor(private readonly messageService: QqbotMessageService) {} + + @Get('conversation/list') + @ApiOperation({ summary: 'QQBot 会话分页' }) + async conversationList(@Query() query: QqbotConversationQueryDto) { + return vbenSuccess(await this.messageService.conversationPage(query)); + } + + @Get('message/list') + @ApiOperation({ summary: 'QQBot 消息分页' }) + async messageList(@Query() query: QqbotMessageQueryDto) { + return vbenSuccess(await this.messageService.messagePage(query)); + } +} diff --git a/src/qqbot/message/qqbot-message.dto.ts b/src/qqbot/message/qqbot-message.dto.ts new file mode 100644 index 0000000..70bc944 --- /dev/null +++ b/src/qqbot/message/qqbot-message.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import type { QqbotMessageType } from '../qqbot.types'; + +export class QqbotConversationQueryDto { + @ApiPropertyOptional({ default: 1 }) + pageNo?: number; + + @ApiPropertyOptional({ default: 10 }) + pageSize?: number; + + @ApiPropertyOptional() + selfId?: string; + + @ApiPropertyOptional() + targetType?: QqbotMessageType; + + @ApiPropertyOptional() + targetId?: string; +} + +export class QqbotMessageQueryDto extends QqbotConversationQueryDto { + @ApiPropertyOptional() + conversationId?: string; + + @ApiPropertyOptional() + keyword?: string; +} diff --git a/src/qqbot/message/qqbot-message.entity.ts b/src/qqbot/message/qqbot-message.entity.ts new file mode 100644 index 0000000..5ebc4be --- /dev/null +++ b/src/qqbot/message/qqbot-message.entity.ts @@ -0,0 +1,73 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotMessageDirection, QqbotMessageType } from '../qqbot.types'; + +@Entity('qqbot_message') +@Index('idx_qqbot_message_self_message', ['selfId', 'messageId']) +export class QqbotMessage { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 64, name: 'self_id' }) + selfId: string; + + @Column({ default: null, length: 64, name: 'message_id', nullable: true }) + messageId: null | string; + + @Column({ + default: null, + length: 64, + name: 'conversation_id', + nullable: true, + }) + conversationId: null | string; + + @Column({ default: 'inbound', length: 32 }) + direction: QqbotMessageDirection; + + @Column({ length: 32, name: 'message_type' }) + messageType: QqbotMessageType; + + @Column({ length: 64, name: 'target_id' }) + targetId: string; + + @Column({ default: null, length: 64, name: 'group_id', nullable: true }) + groupId: null | string; + + @Column({ length: 64, name: 'user_id' }) + userId: string; + + @Column({ default: '', length: 120, name: 'sender_nickname' }) + senderNickname: string; + + @Column({ name: 'raw_message', type: 'text' }) + rawMessage: string; + + @Column({ name: 'message_text', type: 'text' }) + messageText: string; + + @Column({ name: 'raw_event', nullable: true, type: 'simple-json' }) + rawEvent: Record; + + @Column({ name: 'event_time', type: 'datetime' }) + eventTime: Date; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/message/qqbot-message.service.ts b/src/qqbot/message/qqbot-message.service.ts new file mode 100644 index 0000000..edd363a --- /dev/null +++ b/src/qqbot/message/qqbot-message.service.ts @@ -0,0 +1,175 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QqbotConversation } from './qqbot-conversation.entity'; +import { QqbotMessage } from './qqbot-message.entity'; +import type { + QqbotConversationQueryDto, + QqbotMessageQueryDto, +} from './qqbot-message.dto'; +import type { QqbotNormalizedMessage } from '../qqbot.types'; +import { getPageParams } from '../qqbot.utils'; + +@Injectable() +export class QqbotMessageService { + constructor( + @InjectRepository(QqbotConversation) + private readonly conversationRepository: Repository, + @InjectRepository(QqbotMessage) + private readonly messageRepository: Repository, + ) {} + + async conversationPage(query: QqbotConversationQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.conversationRepository + .createQueryBuilder('conversation') + .where('conversation.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.selfId) { + builder.andWhere('conversation.selfId = :selfId', { + selfId: query.selfId, + }); + } + if (query.targetType) { + builder.andWhere('conversation.targetType = :targetType', { + targetType: query.targetType, + }); + } + if (query.targetId) { + builder.andWhere('conversation.targetId LIKE :targetId', { + targetId: `%${query.targetId}%`, + }); + } + + const [list, total] = await builder + .orderBy('conversation.lastMessageTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async messagePage(query: QqbotMessageQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.messageRepository.createQueryBuilder('message'); + + if (query.conversationId) { + builder.andWhere('message.conversationId = :conversationId', { + conversationId: query.conversationId, + }); + } + if (query.selfId) { + builder.andWhere('message.selfId = :selfId', { + selfId: query.selfId, + }); + } + if (query.targetType) { + builder.andWhere('message.messageType = :targetType', { + targetType: query.targetType, + }); + } + if (query.targetId) { + builder.andWhere('message.targetId LIKE :targetId', { + targetId: `%${query.targetId}%`, + }); + } + if (query.keyword) { + builder.andWhere('message.messageText LIKE :keyword', { + keyword: `%${query.keyword}%`, + }); + } + + const [list, total] = await builder + .orderBy('message.eventTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async saveIncoming(message: QqbotNormalizedMessage) { + const conversation = await this.upsertConversation(message); + const entity = this.messageRepository.create({ + conversationId: conversation.id, + direction: 'inbound', + eventTime: message.eventTime, + groupId: message.groupId || null, + messageId: message.messageId, + messageText: message.messageText, + messageType: message.messageType, + rawEvent: message.rawEvent, + rawMessage: message.rawMessage, + selfId: message.selfId, + senderNickname: message.senderNickname || '', + targetId: message.targetId, + userId: message.userId, + }); + return this.messageRepository.save(entity); + } + + async saveOutgoing(params: { + messageId?: string; + messageText: string; + messageType: 'group' | 'private'; + selfId: string; + targetId: string; + userId: string; + }) { + const entity = this.messageRepository.create({ + direction: 'outbound', + eventTime: new Date(), + groupId: params.messageType === 'group' ? params.targetId : null, + messageId: params.messageId || null, + messageText: params.messageText, + messageType: params.messageType, + rawEvent: null, + rawMessage: params.messageText, + selfId: params.selfId, + senderNickname: 'QQBot', + targetId: params.targetId, + userId: params.userId, + }); + return this.messageRepository.save(entity); + } + + private async upsertConversation(message: QqbotNormalizedMessage) { + let conversation = await this.conversationRepository.findOne({ + where: { + isDeleted: false, + selfId: message.selfId, + targetId: message.targetId, + targetType: message.messageType, + }, + }); + + if (!conversation) { + conversation = this.conversationRepository.create({ + lastMessageId: message.messageId, + lastMessageText: message.messageText, + lastMessageTime: message.eventTime, + messageCount: 1, + selfId: message.selfId, + targetId: message.targetId, + targetName: message.senderNickname || message.targetId, + targetType: message.messageType, + }); + return this.conversationRepository.save(conversation); + } + + await this.conversationRepository.update( + { id: conversation.id }, + { + lastMessageId: message.messageId, + lastMessageText: message.messageText, + lastMessageTime: message.eventTime, + messageCount: conversation.messageCount + 1, + targetName: message.senderNickname || conversation.targetName, + }, + ); + return { + ...conversation, + lastMessageId: message.messageId, + messageCount: conversation.messageCount + 1, + }; + } +} diff --git a/src/qqbot/mqtt/qqbot-bus.service.ts b/src/qqbot/mqtt/qqbot-bus.service.ts new file mode 100644 index 0000000..befe37f --- /dev/null +++ b/src/qqbot/mqtt/qqbot-bus.service.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'events'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as mqtt from 'mqtt'; +import type { MqttClient } from 'mqtt'; + +type Handler = (payload: any) => Promise | void; + +@Injectable() +export class QqbotBusService implements OnModuleInit, OnModuleDestroy { + private readonly emitter = new EventEmitter(); + private readonly logger = new Logger(QqbotBusService.name); + private client: MqttClient | null = null; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + if (this.getEventBusMode() !== 'mqtt') return; + + const url = this.configService.get('MQTT_URL'); + if (!url) { + this.logger.warn('QQBot MQTT_URL 未配置,事件总线降级为本地内存模式'); + return; + } + + this.client = mqtt.connect(url, { + clientId: + this.configService.get('MQTT_CLIENT_ID') || + 'kt-template-online-api-qqbot', + password: this.configService.get('MQTT_PASSWORD') || undefined, + username: this.configService.get('MQTT_USERNAME') || undefined, + }); + + this.client.on('connect', () => { + this.logger.log(`QQBot MQTT 已连接: ${url}`); + }); + this.client.on('error', (err) => { + this.logger.warn(`QQBot MQTT 连接异常: ${err.message}`); + }); + } + + async onModuleDestroy() { + if (!this.client) return; + await new Promise((resolve) => { + this.client?.end(false, {}, () => resolve()); + }); + } + + async publish(topic: string, payload: any) { + this.emitter.emit(topic, payload); + + if (!this.client?.connected) return; + this.client.publish(topic, JSON.stringify(payload)); + } + + subscribe(topic: string, handler: Handler) { + this.emitter.on(topic, handler); + return () => this.emitter.off(topic, handler); + } + + getStatus() { + return { + connected: !!this.client?.connected, + mode: this.getEventBusMode(), + url: this.maskUrl(this.configService.get('MQTT_URL') || ''), + }; + } + + private getEventBusMode() { + return this.configService.get('QQBOT_EVENT_BUS') || 'local'; + } + + private maskUrl(url: string) { + if (!url) return ''; + return url.replace(/:\/\/([^:@]+):([^@]+)@/, '://***:***@'); + } +} diff --git a/src/qqbot/permission/qqbot-allowlist.entity.ts b/src/qqbot/permission/qqbot-allowlist.entity.ts new file mode 100644 index 0000000..bee498b --- /dev/null +++ b/src/qqbot/permission/qqbot-allowlist.entity.ts @@ -0,0 +1,45 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotPermissionTargetType } from '../qqbot.types'; + +@Entity('qqbot_allowlist') +export class QqbotAllowlist { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ default: '', length: 64, name: 'self_id' }) + selfId: string; + + @Column({ default: 'all', length: 32, name: 'target_type' }) + targetType: QqbotPermissionTargetType; + + @Column({ default: '', length: 64, name: 'target_id' }) + targetId: string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/permission/qqbot-blocklist.entity.ts b/src/qqbot/permission/qqbot-blocklist.entity.ts new file mode 100644 index 0000000..204c937 --- /dev/null +++ b/src/qqbot/permission/qqbot-blocklist.entity.ts @@ -0,0 +1,45 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotPermissionTargetType } from '../qqbot.types'; + +@Entity('qqbot_blocklist') +export class QqbotBlocklist { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ default: '', length: 64, name: 'self_id' }) + selfId: string; + + @Column({ default: 'all', length: 32, name: 'target_type' }) + targetType: QqbotPermissionTargetType; + + @Column({ default: '', length: 64, name: 'target_id' }) + targetId: string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/permission/qqbot-permission.controller.ts b/src/qqbot/permission/qqbot-permission.controller.ts new file mode 100644 index 0000000..b65f6aa --- /dev/null +++ b/src/qqbot/permission/qqbot-permission.controller.ts @@ -0,0 +1,82 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { + QqbotPermissionBodyDto, + QqbotPermissionQueryDto, + QqbotPermissionUpdateDto, +} from './qqbot-permission.dto'; +import { QqbotPermissionService } from './qqbot-permission.service'; + +@ApiTags('qqbot-permission') +@Controller('qqbot/permission') +@UseGuards(JwtAuthGuard) +export class QqbotPermissionController { + constructor(private readonly permissionService: QqbotPermissionService) {} + + @Get('allowlist') + @ApiOperation({ summary: 'QQBot 白名单分页' }) + async allowlist(@Query() query: QqbotPermissionQueryDto) { + return vbenSuccess(await this.permissionService.page('allowlist', query)); + } + + @Post('allowlist/save') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '新增 QQBot 白名单' }) + async saveAllowlist(@Body() body: QqbotPermissionBodyDto) { + return vbenSuccess(await this.permissionService.save('allowlist', body)); + } + + @Post('allowlist/update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '编辑 QQBot 白名单' }) + async updateAllowlist(@Body() body: QqbotPermissionUpdateDto) { + return vbenSuccess(await this.permissionService.update('allowlist', body)); + } + + @Post('allowlist/delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除 QQBot 白名单' }) + @ApiQuery({ name: 'id', type: String }) + async deleteAllowlist(@Query('id') id: string) { + return vbenSuccess(await this.permissionService.remove('allowlist', id)); + } + + @Get('blocklist') + @ApiOperation({ summary: 'QQBot 黑名单分页' }) + async blocklist(@Query() query: QqbotPermissionQueryDto) { + return vbenSuccess(await this.permissionService.page('blocklist', query)); + } + + @Post('blocklist/save') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '新增 QQBot 黑名单' }) + async saveBlocklist(@Body() body: QqbotPermissionBodyDto) { + return vbenSuccess(await this.permissionService.save('blocklist', body)); + } + + @Post('blocklist/update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '编辑 QQBot 黑名单' }) + async updateBlocklist(@Body() body: QqbotPermissionUpdateDto) { + return vbenSuccess(await this.permissionService.update('blocklist', body)); + } + + @Post('blocklist/delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除 QQBot 黑名单' }) + @ApiQuery({ name: 'id', type: String }) + async deleteBlocklist(@Query('id') id: string) { + return vbenSuccess(await this.permissionService.remove('blocklist', id)); + } +} diff --git a/src/qqbot/permission/qqbot-permission.dto.ts b/src/qqbot/permission/qqbot-permission.dto.ts new file mode 100644 index 0000000..73fe65d --- /dev/null +++ b/src/qqbot/permission/qqbot-permission.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import type { QqbotPermissionTargetType } from '../qqbot.types'; + +export class QqbotPermissionBodyDto { + @ApiPropertyOptional({ example: '10000' }) + selfId?: string; + + @ApiProperty({ default: 'private' }) + targetType: QqbotPermissionTargetType; + + @ApiProperty({ example: '123456' }) + targetId: string; + + @ApiPropertyOptional({ default: true }) + enabled?: boolean; + + @ApiPropertyOptional() + remark?: string; +} + +export class QqbotPermissionUpdateDto extends PartialType( + QqbotPermissionBodyDto, +) { + @ApiProperty() + id: string; +} + +export class QqbotPermissionQueryDto { + @ApiPropertyOptional({ default: 1 }) + pageNo?: number; + + @ApiPropertyOptional({ default: 10 }) + pageSize?: number; + + @ApiPropertyOptional() + selfId?: string; + + @ApiPropertyOptional() + targetType?: QqbotPermissionTargetType; + + @ApiPropertyOptional() + targetId?: string; +} diff --git a/src/qqbot/permission/qqbot-permission.service.ts b/src/qqbot/permission/qqbot-permission.service.ts new file mode 100644 index 0000000..3a52528 --- /dev/null +++ b/src/qqbot/permission/qqbot-permission.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QqbotAllowlist } from './qqbot-allowlist.entity'; +import { QqbotBlocklist } from './qqbot-blocklist.entity'; +import type { + QqbotPermissionBodyDto, + QqbotPermissionQueryDto, + QqbotPermissionUpdateDto, +} from './qqbot-permission.dto'; +import type { QqbotNormalizedMessage } from '../qqbot.types'; +import { getPageParams } from '../qqbot.utils'; + +type PermissionKind = 'allowlist' | 'blocklist'; +type PermissionEntity = QqbotAllowlist | QqbotBlocklist; + +@Injectable() +export class QqbotPermissionService { + constructor( + @InjectRepository(QqbotAllowlist) + private readonly allowlistRepository: Repository, + @InjectRepository(QqbotBlocklist) + private readonly blocklistRepository: Repository, + ) {} + + async page(kind: PermissionKind, query: QqbotPermissionQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const repository = this.getRepository(kind); + const builder = repository + .createQueryBuilder('permission') + .where('permission.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.selfId) { + builder.andWhere('permission.selfId = :selfId', { + selfId: query.selfId, + }); + } + if (query.targetType) { + builder.andWhere('permission.targetType = :targetType', { + targetType: query.targetType, + }); + } + if (query.targetId) { + builder.andWhere('permission.targetId LIKE :targetId', { + targetId: `%${query.targetId}%`, + }); + } + + const [list, total] = await builder + .orderBy('permission.createTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async save(kind: PermissionKind, body: QqbotPermissionBodyDto) { + const repository = this.getRepository(kind); + const saved = await repository.save( + repository.create({ + enabled: body.enabled ?? true, + remark: body.remark || '', + selfId: body.selfId || '', + targetId: body.targetId || '', + targetType: body.targetType || 'all', + } as PermissionEntity), + ); + return saved.id; + } + + async update(kind: PermissionKind, body: QqbotPermissionUpdateDto) { + const repository = this.getRepository(kind); + await repository.update( + { id: body.id } as any, + { + enabled: body.enabled ?? true, + remark: body.remark || '', + selfId: body.selfId || '', + targetId: body.targetId || '', + targetType: body.targetType || 'all', + } as any, + ); + return true; + } + + async remove(kind: PermissionKind, id: string) { + const repository = this.getRepository(kind); + await repository.update({ id } as any, { isDeleted: true } as any); + return true; + } + + async isBlocked(message: QqbotNormalizedMessage) { + return this.existsMatched(this.blocklistRepository, message); + } + + async isAllowed(message: QqbotNormalizedMessage) { + const requireAllowlist = + `${process.env.QQBOT_REQUIRE_ALLOWLIST ?? 'true'}` !== 'false'; + if (!requireAllowlist) return true; + + const hasRule = await this.allowlistRepository.count({ + where: { + enabled: true, + isDeleted: false, + }, + }); + if (hasRule <= 0) return false; + return this.existsMatched(this.allowlistRepository, message); + } + + private async existsMatched( + repository: Repository, + message: QqbotNormalizedMessage, + ) { + const count = await repository + .createQueryBuilder('permission') + .where('permission.isDeleted = :isDeleted', { isDeleted: false }) + .andWhere('permission.enabled = :enabled', { enabled: true }) + .andWhere('(permission.selfId = :selfId OR permission.selfId = :empty)', { + empty: '', + selfId: message.selfId, + }) + .andWhere( + '(permission.targetType = :all OR (permission.targetType = :targetType AND permission.targetId = :targetId))', + { + all: 'all', + targetId: message.targetId, + targetType: message.messageType, + }, + ) + .getCount(); + return count > 0; + } + + private getRepository(kind: PermissionKind) { + return kind === 'allowlist' + ? this.allowlistRepository + : this.blocklistRepository; + } +} diff --git a/src/qqbot/qqbot.constants.ts b/src/qqbot/qqbot.constants.ts new file mode 100644 index 0000000..a6b0f46 --- /dev/null +++ b/src/qqbot/qqbot.constants.ts @@ -0,0 +1,13 @@ +export const QQBOT_REVERSE_WS_PATH = '/qqbot/onebot/reverse'; + +export const QQBOT_MQTT_TOPICS = { + commandSend: (selfId: string) => `qqbot/${selfId}/command/send`, + eventMessage: (selfId: string) => `qqbot/${selfId}/event/message`, + eventRaw: (selfId: string) => `qqbot/${selfId}/event/raw`, + response: (selfId: string, echo: string) => + `qqbot/${selfId}/api/response/${echo}`, + status: (selfId: string) => `qqbot/${selfId}/status/runtime`, +}; + +export const QQBOT_DEFAULT_PAGE_NO = 1; +export const QQBOT_DEFAULT_PAGE_SIZE = 10; diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts new file mode 100644 index 0000000..db45f7a --- /dev/null +++ b/src/qqbot/qqbot.module.ts @@ -0,0 +1,71 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; +import { QqbotAccountController } from './account/qqbot-account.controller'; +import { QqbotAccount } from './account/qqbot-account.entity'; +import { QqbotAccountService } from './account/qqbot-account.service'; +import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service'; +import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller'; +import { QqbotDashboardService } from './dashboard/qqbot-dashboard.service'; +import { QqbotDedupe } from './dedupe/qqbot-dedupe.entity'; +import { QqbotDedupeService } from './dedupe/qqbot-dedupe.service'; +import { QqbotEventService } from './event/qqbot-event.service'; +import { QqbotConversation } from './message/qqbot-conversation.entity'; +import { QqbotMessageController } from './message/qqbot-message.controller'; +import { QqbotMessage } from './message/qqbot-message.entity'; +import { QqbotMessageService } from './message/qqbot-message.service'; +import { QqbotBusService } from './mqtt/qqbot-bus.service'; +import { QqbotAllowlist } from './permission/qqbot-allowlist.entity'; +import { QqbotBlocklist } from './permission/qqbot-blocklist.entity'; +import { QqbotPermissionController } from './permission/qqbot-permission.controller'; +import { QqbotPermissionService } from './permission/qqbot-permission.service'; +import { QqbotRuleController } from './rule/qqbot-rule.controller'; +import { QqbotRule } from './rule/qqbot-rule.entity'; +import { QqbotRuleEngineService } from './rule/qqbot-rule-engine.service'; +import { QqbotRuleService } from './rule/qqbot-rule.service'; +import { QqbotRateLimitService } from './send/qqbot-rate-limit.service'; +import { QqbotSendController } from './send/qqbot-send.controller'; +import { QqbotSendLog } from './send/qqbot-send-log.entity'; +import { QqbotSendService } from './send/qqbot-send.service'; + +@Module({ + imports: [ + ConfigModule, + AdminAuthGuardModule, + TypeOrmModule.forFeature([ + QqbotAccount, + QqbotAllowlist, + QqbotBlocklist, + QqbotConversation, + QqbotDedupe, + QqbotMessage, + QqbotRule, + QqbotSendLog, + ]), + ], + controllers: [ + QqbotAccountController, + QqbotDashboardController, + QqbotMessageController, + QqbotPermissionController, + QqbotRuleController, + QqbotSendController, + ], + providers: [ + QqbotAccountService, + QqbotBusService, + QqbotDashboardService, + QqbotDedupeService, + QqbotEventService, + QqbotMessageService, + QqbotPermissionService, + QqbotRateLimitService, + QqbotReverseWsService, + QqbotRuleEngineService, + QqbotRuleService, + QqbotSendService, + ], + exports: [QqbotAccountService, QqbotReverseWsService], +}) +export class QqbotModule {} diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts new file mode 100644 index 0000000..00b9871 --- /dev/null +++ b/src/qqbot/qqbot.types.ts @@ -0,0 +1,52 @@ +export type QqbotConnectionMode = 'reverse-ws'; + +export type QqbotConnectionRole = 'API' | 'Event' | 'Universal'; + +export type QqbotConnectionStatus = 'offline' | 'online'; + +export type QqbotMessageDirection = 'inbound' | 'outbound'; + +export type QqbotMessageType = 'group' | 'private'; + +export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex'; + +export type QqbotRuleTargetType = 'all' | 'group' | 'private'; + +export type QqbotSendStatus = 'failed' | 'pending' | 'success'; + +export type QqbotPermissionTargetType = 'all' | 'group' | 'private'; + +export type QqbotOneBotEvent = Record & { + group_id?: number | string; + message?: any; + message_id?: number | string; + message_type?: QqbotMessageType; + post_type?: string; + raw_message?: string; + self_id?: number | string; + sender?: Record; + time?: number; + user_id?: number | string; +}; + +export type QqbotNormalizedMessage = { + eventTime: Date; + groupId?: string; + messageId: string; + messageText: string; + messageType: QqbotMessageType; + rawEvent: QqbotOneBotEvent; + rawMessage: string; + selfId: string; + senderNickname?: string; + targetId: string; + userId: string; +}; + +export type QqbotOneBotActionResponse = { + data?: any; + echo?: string; + message?: string; + retcode?: number; + status?: string; +}; diff --git a/src/qqbot/qqbot.utils.ts b/src/qqbot/qqbot.utils.ts new file mode 100644 index 0000000..745466e --- /dev/null +++ b/src/qqbot/qqbot.utils.ts @@ -0,0 +1,41 @@ +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from './qqbot.constants'; + +export type QqbotPageQuery = { + pageNo?: number | string; + pageSize?: number | string; +}; + +export function toStringId(value: number | string | undefined) { + return value === undefined || value === null ? '' : `${value}`; +} + +export function toNumber(value: number | string | undefined, fallback: number) { + const nextValue = Number(value); + return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback; +} + +export function getPageParams(query: QqbotPageQuery = {}) { + const pageNo = toNumber(query.pageNo, QQBOT_DEFAULT_PAGE_NO); + const pageSize = toNumber(query.pageSize, QQBOT_DEFAULT_PAGE_SIZE); + return { + pageNo, + pageSize, + skip: (pageNo - 1) * pageSize, + }; +} + +export function normalizeBoolean(value: any, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value === 1; + return ['1', 'true', 'yes'].includes(`${value}`.toLowerCase()); +} + +export function normalizeNullableString(value: any) { + if (value === undefined || value === null) return null; + const nextValue = `${value}`.trim(); + return nextValue ? nextValue : null; +} diff --git a/src/qqbot/rule/qqbot-rule-engine.service.ts b/src/qqbot/rule/qqbot-rule-engine.service.ts new file mode 100644 index 0000000..35c745b --- /dev/null +++ b/src/qqbot/rule/qqbot-rule-engine.service.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { QqbotNormalizedMessage } from '../qqbot.types'; +import { QqbotPermissionService } from '../permission/qqbot-permission.service'; +import { QqbotSendService } from '../send/qqbot-send.service'; +import { QqbotRuleService } from './qqbot-rule.service'; + +@Injectable() +export class QqbotRuleEngineService { + private readonly logger = new Logger(QqbotRuleEngineService.name); + + constructor( + private readonly permissionService: QqbotPermissionService, + private readonly ruleService: QqbotRuleService, + private readonly sendService: QqbotSendService, + ) {} + + async handleMessage(message: QqbotNormalizedMessage) { + if (await this.permissionService.isBlocked(message)) return; + if (!(await this.permissionService.isAllowed(message))) return; + + const rules = await this.ruleService.listEnabledForMessage(message); + for (const rule of rules) { + if (this.ruleService.isInCooldown(rule)) continue; + if (!this.ruleService.isMatched(rule, message)) continue; + + await this.ruleService.markHit(rule); + try { + await this.sendService.sendText({ + message: rule.replyContent, + selfId: message.selfId, + targetId: message.targetId, + targetType: message.messageType, + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : '自动回复失败'; + this.logger.warn(`QQBot 自动回复失败: ${errMsg}`); + } + return; + } + } +} diff --git a/src/qqbot/rule/qqbot-rule.controller.ts b/src/qqbot/rule/qqbot-rule.controller.ts new file mode 100644 index 0000000..20e3ff3 --- /dev/null +++ b/src/qqbot/rule/qqbot-rule.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { + QqbotRuleBodyDto, + QqbotRuleQueryDto, + QqbotRuleUpdateDto, +} from './qqbot-rule.dto'; +import { QqbotRuleService } from './qqbot-rule.service'; +import { normalizeBoolean } from '../qqbot.utils'; + +@ApiTags('qqbot-rule') +@Controller('qqbot/rule') +@UseGuards(JwtAuthGuard) +export class QqbotRuleController { + constructor(private readonly ruleService: QqbotRuleService) {} + + @Get('list') + @ApiOperation({ summary: 'QQBot 自动回复规则分页' }) + async list(@Query() query: QqbotRuleQueryDto) { + return vbenSuccess(await this.ruleService.page(query)); + } + + @Post('save') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '新增 QQBot 自动回复规则' }) + async save(@Body() body: QqbotRuleBodyDto) { + return vbenSuccess(await this.ruleService.save(body)); + } + + @Post('update') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '编辑 QQBot 自动回复规则' }) + async update(@Body() body: QqbotRuleUpdateDto) { + return vbenSuccess(await this.ruleService.update(body)); + } + + @Post('delete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '删除 QQBot 自动回复规则' }) + @ApiQuery({ name: 'id', type: String }) + async delete(@Query('id') id: string) { + return vbenSuccess(await this.ruleService.remove(id)); + } + + @Post('toggle') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '启停 QQBot 自动回复规则' }) + @ApiQuery({ name: 'id', type: String }) + @ApiQuery({ name: 'enabled', type: Boolean }) + async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { + return vbenSuccess( + await this.ruleService.toggle(id, normalizeBoolean(enabled)), + ); + } +} diff --git a/src/qqbot/rule/qqbot-rule.dto.ts b/src/qqbot/rule/qqbot-rule.dto.ts new file mode 100644 index 0000000..6731798 --- /dev/null +++ b/src/qqbot/rule/qqbot-rule.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types'; + +export class QqbotRuleBodyDto { + @ApiPropertyOptional() + name?: string; + + @ApiProperty({ default: 'keyword' }) + matchType: QqbotRuleMatchType; + + @ApiProperty({ example: 'ping' }) + keyword: string; + + @ApiPropertyOptional({ default: 'all' }) + targetType?: QqbotRuleTargetType; + + @ApiProperty({ example: 'pong' }) + replyContent: string; + + @ApiPropertyOptional({ default: true }) + enabled?: boolean; + + @ApiPropertyOptional({ default: 0 }) + priority?: number; + + @ApiPropertyOptional({ default: 1500 }) + cooldownMs?: number; + + @ApiPropertyOptional() + remark?: string; +} + +export class QqbotRuleUpdateDto extends PartialType(QqbotRuleBodyDto) { + @ApiProperty() + id: string; +} + +export class QqbotRuleQueryDto { + @ApiPropertyOptional({ default: 1 }) + pageNo?: number; + + @ApiPropertyOptional({ default: 10 }) + pageSize?: number; + + @ApiPropertyOptional() + keyword?: string; + + @ApiPropertyOptional() + targetType?: QqbotRuleTargetType; + + @ApiPropertyOptional() + enabled?: boolean; +} diff --git a/src/qqbot/rule/qqbot-rule.entity.ts b/src/qqbot/rule/qqbot-rule.entity.ts new file mode 100644 index 0000000..ce5f8f1 --- /dev/null +++ b/src/qqbot/rule/qqbot-rule.entity.ts @@ -0,0 +1,65 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types'; + +@Entity('qqbot_rule') +export class QqbotRule { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ default: '', length: 120 }) + name: string; + + @Column({ default: 'keyword', length: 32, name: 'match_type' }) + matchType: QqbotRuleMatchType; + + @Column({ length: 500 }) + keyword: string; + + @Column({ length: 32, name: 'target_type', default: 'all' }) + targetType: QqbotRuleTargetType; + + @Column({ name: 'reply_content', type: 'text' }) + replyContent: string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: 0 }) + priority: number; + + @Column({ default: 1500, name: 'cooldown_ms' }) + cooldownMs: number; + + @Column({ + default: null, + name: 'last_hit_at', + nullable: true, + type: 'datetime', + }) + lastHitAt: Date | null; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/rule/qqbot-rule.service.ts b/src/qqbot/rule/qqbot-rule.service.ts new file mode 100644 index 0000000..667889e --- /dev/null +++ b/src/qqbot/rule/qqbot-rule.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { QqbotRule } from './qqbot-rule.entity'; +import type { + QqbotRuleBodyDto, + QqbotRuleQueryDto, + QqbotRuleUpdateDto, +} from './qqbot-rule.dto'; +import type { + QqbotNormalizedMessage, + QqbotRuleMatchType, + QqbotRuleTargetType, +} from '../qqbot.types'; +import { getPageParams, normalizeBoolean } from '../qqbot.utils'; + +@Injectable() +export class QqbotRuleService { + constructor( + @InjectRepository(QqbotRule) + private readonly ruleRepository: Repository, + ) {} + + async page(query: QqbotRuleQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.ruleRepository + .createQueryBuilder('rule') + .where('rule.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.keyword) { + builder.andWhere( + '(rule.name LIKE :keyword OR rule.keyword LIKE :keyword)', + { + keyword: `%${query.keyword}%`, + }, + ); + } + if (query.targetType) { + builder.andWhere('rule.targetType = :targetType', { + targetType: query.targetType, + }); + } + if (query.enabled !== undefined && `${query.enabled}` !== '') { + builder.andWhere('rule.enabled = :enabled', { + enabled: normalizeBoolean(query.enabled), + }); + } + + const [list, total] = await builder + .orderBy('rule.priority', 'DESC') + .addOrderBy('rule.createTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async listEnabledForMessage(message: QqbotNormalizedMessage) { + return this.ruleRepository + .createQueryBuilder('rule') + .where('rule.isDeleted = :isDeleted', { isDeleted: false }) + .andWhere('rule.enabled = :enabled', { enabled: true }) + .andWhere('rule.targetType IN (:...targetTypes)', { + targetTypes: ['all', message.messageType], + }) + .orderBy('rule.priority', 'DESC') + .addOrderBy('rule.createTime', 'ASC') + .getMany(); + } + + async save(body: QqbotRuleBodyDto) { + this.assertRuleValid(body.matchType, body.keyword); + const saved = await this.ruleRepository.save( + this.ruleRepository.create(this.normalizeBody(body)), + ); + return saved.id; + } + + async update(body: QqbotRuleUpdateDto) { + if (body.matchType || body.keyword) { + this.assertRuleValid(body.matchType || 'keyword', body.keyword || ''); + } + const payload = this.normalizeBody(body); + delete (payload as any).id; + await this.ruleRepository.update({ id: body.id }, payload); + return true; + } + + async remove(id: string) { + await this.ruleRepository.update({ id }, { isDeleted: true }); + return true; + } + + async toggle(id: string, enabled: boolean) { + await this.ruleRepository.update({ id }, { enabled }); + return true; + } + + async markHit(rule: QqbotRule) { + await this.ruleRepository.update( + { id: rule.id }, + { lastHitAt: new Date() }, + ); + } + + isMatched(rule: QqbotRule, message: QqbotNormalizedMessage) { + const source = message.messageText || ''; + if (!source) return false; + + if (rule.matchType === 'equals') return source === rule.keyword; + if (rule.matchType === 'regex') { + try { + return new RegExp(rule.keyword).test(source); + } catch { + return false; + } + } + return source.includes(rule.keyword); + } + + isInCooldown(rule: QqbotRule) { + if (!rule.lastHitAt || !rule.cooldownMs) return false; + return Date.now() - new Date(rule.lastHitAt).getTime() < rule.cooldownMs; + } + + private assertRuleValid(matchType: QqbotRuleMatchType, keyword: string) { + if (!keyword?.trim()) { + throwVbenError('规则关键词不能为空'); + } + if (matchType === 'regex') { + try { + new RegExp(keyword); + } catch { + throwVbenError('正则表达式不合法'); + } + } + } + + private normalizeBody(body: Partial) { + return { + cooldownMs: Number(body.cooldownMs ?? 1500), + enabled: body.enabled ?? true, + keyword: body.keyword || '', + matchType: (body.matchType || 'keyword') as QqbotRuleMatchType, + name: body.name || body.keyword || '', + priority: Number(body.priority || 0), + remark: body.remark || '', + replyContent: body.replyContent || '', + targetType: (body.targetType || 'all') as QqbotRuleTargetType, + } as Partial; + } +} diff --git a/src/qqbot/send/qqbot-rate-limit.service.ts b/src/qqbot/send/qqbot-rate-limit.service.ts new file mode 100644 index 0000000..8575b37 --- /dev/null +++ b/src/qqbot/send/qqbot-rate-limit.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { throwVbenError } from '@/common'; + +@Injectable() +export class QqbotRateLimitService { + private readonly lastSentAt = new Map(); + + constructor(private readonly configService: ConfigService) {} + + assertCanSend(selfId: string, targetId: string) { + const now = Date.now(); + const globalKey = `${selfId}:global`; + const targetKey = `${selfId}:${targetId}`; + const minInterval = Math.ceil(1000 / this.getRatePerSecond()); + + this.assertInterval(globalKey, now, minInterval, 'QQBot 全局发送过快'); + this.assertInterval(targetKey, now, 1000, 'QQBot 单会话发送过快'); + + this.lastSentAt.set(globalKey, now); + this.lastSentAt.set(targetKey, now); + } + + private assertInterval( + key: string, + now: number, + minInterval: number, + msg: string, + ) { + const last = this.lastSentAt.get(key) || 0; + if (now - last < minInterval) { + throwVbenError(msg); + } + } + + private getRatePerSecond() { + const value = Number( + this.configService.get('QQBOT_SEND_RATE_PER_SECOND') || 1, + ); + return Number.isFinite(value) && value > 0 ? value : 1; + } +} diff --git a/src/qqbot/send/qqbot-send-log.entity.ts b/src/qqbot/send/qqbot-send-log.entity.ts new file mode 100644 index 0000000..36f73e5 --- /dev/null +++ b/src/qqbot/send/qqbot-send-log.entity.ts @@ -0,0 +1,62 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types'; + +@Entity('qqbot_send_log') +@Index('idx_qqbot_send_log_target', ['selfId', 'targetType', 'targetId']) +export class QqbotSendLog { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 64, name: 'self_id' }) + selfId: string; + + @Column({ length: 32, name: 'target_type' }) + targetType: QqbotMessageType; + + @Column({ length: 64, name: 'target_id' }) + targetId: string; + + @Column({ length: 64 }) + action: string; + + @Column({ name: 'message_text', type: 'text' }) + messageText: string; + + @Column({ nullable: true, type: 'simple-json' }) + params: Record; + + @Column({ default: 'pending', length: 32 }) + status: QqbotSendStatus; + + @Column({ default: null, length: 80, nullable: true }) + echo: null | string; + + @Column({ default: null, length: 64, name: 'message_id', nullable: true }) + messageId: null | string; + + @Column({ default: null, length: 500, name: 'error_message', nullable: true }) + errorMessage: null | string; + + @Column({ nullable: true, type: 'simple-json' }) + response: Record; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/send/qqbot-send.controller.ts b/src/qqbot/send/qqbot-send.controller.ts new file mode 100644 index 0000000..3330997 --- /dev/null +++ b/src/qqbot/send/qqbot-send.controller.ts @@ -0,0 +1,46 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; +import { vbenSuccess } from '@/common'; +import { + QqbotSendGroupDto, + QqbotSendLogQueryDto, + QqbotSendPrivateDto, +} from './qqbot-send.dto'; +import { QqbotSendService } from './qqbot-send.service'; + +@ApiTags('qqbot-send') +@Controller('qqbot/send') +@UseGuards(JwtAuthGuard) +export class QqbotSendController { + constructor(private readonly sendService: QqbotSendService) {} + + @Get('log/list') + @ApiOperation({ summary: 'QQBot 发送日志分页' }) + async logList(@Query() query: QqbotSendLogQueryDto) { + return vbenSuccess(await this.sendService.logPage(query)); + } + + @Post('private') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'QQBot 发送私聊消息' }) + async private(@Body() body: QqbotSendPrivateDto) { + return vbenSuccess(await this.sendService.sendPrivate(body)); + } + + @Post('group') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'QQBot 发送群聊消息' }) + async group(@Body() body: QqbotSendGroupDto) { + return vbenSuccess(await this.sendService.sendGroup(body)); + } +} diff --git a/src/qqbot/send/qqbot-send.dto.ts b/src/qqbot/send/qqbot-send.dto.ts new file mode 100644 index 0000000..55efdb1 --- /dev/null +++ b/src/qqbot/send/qqbot-send.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types'; + +export class QqbotSendPrivateDto { + @ApiPropertyOptional() + selfId?: string; + + @ApiProperty({ example: '123456' }) + userId: string; + + @ApiProperty({ example: '你好' }) + message: string; +} + +export class QqbotSendGroupDto { + @ApiPropertyOptional() + selfId?: string; + + @ApiProperty({ example: '123456' }) + groupId: string; + + @ApiProperty({ example: '你好' }) + message: string; +} + +export class QqbotSendLogQueryDto { + @ApiPropertyOptional({ default: 1 }) + pageNo?: number; + + @ApiPropertyOptional({ default: 10 }) + pageSize?: number; + + @ApiPropertyOptional() + selfId?: string; + + @ApiPropertyOptional() + targetType?: QqbotMessageType; + + @ApiPropertyOptional() + targetId?: string; + + @ApiPropertyOptional() + status?: QqbotSendStatus; +} diff --git a/src/qqbot/send/qqbot-send.service.ts b/src/qqbot/send/qqbot-send.service.ts new file mode 100644 index 0000000..b90473a --- /dev/null +++ b/src/qqbot/send/qqbot-send.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { QqbotAccountService } from '../account/qqbot-account.service'; +import type { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; +import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; +import { QqbotBusService } from '../mqtt/qqbot-bus.service'; +import { QqbotMessageService } from '../message/qqbot-message.service'; +import type { QqbotMessageType } from '../qqbot.types'; +import { getPageParams } from '../qqbot.utils'; +import { QqbotRateLimitService } from './qqbot-rate-limit.service'; +import { QqbotSendLog } from './qqbot-send-log.entity'; +import type { + QqbotSendGroupDto, + QqbotSendLogQueryDto, + QqbotSendPrivateDto, +} from './qqbot-send.dto'; + +@Injectable() +export class QqbotSendService { + constructor( + @InjectRepository(QqbotSendLog) + private readonly sendLogRepository: Repository, + private readonly accountService: QqbotAccountService, + private readonly busService: QqbotBusService, + private readonly messageService: QqbotMessageService, + private readonly moduleRef: ModuleRef, + private readonly rateLimitService: QqbotRateLimitService, + ) {} + + async logPage(query: QqbotSendLogQueryDto) { + const { pageNo, pageSize, skip } = getPageParams(query); + const builder = this.sendLogRepository.createQueryBuilder('log'); + + if (query.selfId) { + builder.andWhere('log.selfId = :selfId', { selfId: query.selfId }); + } + if (query.targetType) { + builder.andWhere('log.targetType = :targetType', { + targetType: query.targetType, + }); + } + if (query.targetId) { + builder.andWhere('log.targetId LIKE :targetId', { + targetId: `%${query.targetId}%`, + }); + } + if (query.status) { + builder.andWhere('log.status = :status', { status: query.status }); + } + + const [list, total] = await builder + .orderBy('log.createTime', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { list, pageNo, pageSize, total }; + } + + async sendPrivate(body: QqbotSendPrivateDto) { + return this.sendText({ + message: body.message, + selfId: body.selfId, + targetId: body.userId, + targetType: 'private', + }); + } + + async sendGroup(body: QqbotSendGroupDto) { + return this.sendText({ + message: body.message, + selfId: body.selfId, + targetId: body.groupId, + targetType: 'group', + }); + } + + async sendText(params: { + message: string; + selfId?: string; + targetId: string; + targetType: QqbotMessageType; + }) { + const account = await this.accountService.getDefaultAccount(params.selfId); + if (!account) { + throwVbenError('没有可用 QQBot 账号'); + } + + this.rateLimitService.assertCanSend(account.selfId, params.targetId); + + const action = + params.targetType === 'group' ? 'send_group_msg' : 'send_private_msg'; + const actionParams = + params.targetType === 'group' + ? { group_id: params.targetId, message: params.message } + : { message: params.message, user_id: params.targetId }; + + const log = await this.sendLogRepository.save( + this.sendLogRepository.create({ + action, + messageText: params.message, + params: actionParams, + selfId: account.selfId, + status: 'pending', + targetId: params.targetId, + targetType: params.targetType, + }), + ); + + await this.busService.publish( + QQBOT_MQTT_TOPICS.commandSend(account.selfId), + { + action, + logId: log.id, + params: actionParams, + selfId: account.selfId, + }, + ); + + try { + const reverseWsService = await this.getReverseWsService(); + const response = await reverseWsService.sendAction( + account.selfId, + action, + actionParams, + ); + const success = response.status === 'ok' || response.retcode === 0; + const messageId = response.data?.message_id + ? `${response.data.message_id}` + : null; + await this.sendLogRepository.update( + { id: log.id }, + { + echo: response.echo || null, + errorMessage: success ? null : response.message || 'OneBot 发送失败', + messageId, + response: response as any, + status: success ? 'success' : 'failed', + }, + ); + + if (success) { + await this.messageService.saveOutgoing({ + messageId, + messageText: params.message, + messageType: params.targetType, + selfId: account.selfId, + targetId: params.targetId, + userId: + params.targetType === 'private' ? params.targetId : account.selfId, + }); + } + if (!success) throwVbenError(response.message || 'OneBot 发送失败'); + return { ...response, logId: log.id }; + } catch (err) { + const message = err instanceof Error ? err.message : 'OneBot 发送失败'; + await this.sendLogRepository.update( + { id: log.id }, + { + errorMessage: message, + status: 'failed', + }, + ); + throwVbenError(message); + } + } + + private async getReverseWsService() { + const { QqbotReverseWsService } = await import( + '../connection/qqbot-reverse-ws.service' + ); + return this.moduleRef.get(QqbotReverseWsService, { + strict: false, + }); + } +}