feat: 接入QQBot后端运行时

This commit is contained in:
sunlei 2026-05-30 20:47:47 +08:00
parent 8139279fb6
commit a06cf0ee3f
41 changed files with 3154 additions and 3 deletions

View File

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

View File

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

View File

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

189
sql/qqbot-init.sql Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<QqbotAccount>,
) {}
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<QqbotAccountBodyDto>) {
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<QqbotAccount>;
}
}

View File

@ -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<string, WebSocket>();
private readonly pendingActions = new Map<string, PendingAction>();
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<string, any>,
) {
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<QqbotOneBotActionResponse>(
(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<string>('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<string>('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'] || ''}`
);
}
}

View File

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

View File

@ -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<QqbotAccount>,
@InjectRepository(QqbotConversation)
private readonly conversationRepository: Repository<QqbotConversation>,
@InjectRepository(QqbotMessage)
private readonly messageRepository: Repository<QqbotMessage>,
@InjectRepository(QqbotRule)
private readonly ruleRepository: Repository<QqbotRule>,
@InjectRepository(QqbotSendLog)
private readonly sendLogRepository: Repository<QqbotSendLog>,
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,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, any>;
@Column({ name: 'event_time', type: 'datetime' })
eventTime: Date;
@CreateDateColumn({ name: 'create_time' })
createTime: Date;
@UpdateDateColumn({ name: 'update_time' })
updateTime: Date;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -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<QqbotConversation>,
@InjectRepository(QqbotMessage)
private readonly messageRepository: Repository<QqbotMessage>,
) {}
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,
};
}
}

View File

@ -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> | 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<string>('MQTT_URL');
if (!url) {
this.logger.warn('QQBot MQTT_URL 未配置,事件总线降级为本地内存模式');
return;
}
this.client = mqtt.connect(url, {
clientId:
this.configService.get<string>('MQTT_CLIENT_ID') ||
'kt-template-online-api-qqbot',
password: this.configService.get<string>('MQTT_PASSWORD') || undefined,
username: this.configService.get<string>('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<void>((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<string>('MQTT_URL') || ''),
};
}
private getEventBusMode() {
return this.configService.get<string>('QQBOT_EVENT_BUS') || 'local';
}
private maskUrl(url: string) {
if (!url) return '';
return url.replace(/:\/\/([^:@]+):([^@]+)@/, '://***:***@');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<QqbotAllowlist>,
@InjectRepository(QqbotBlocklist)
private readonly blocklistRepository: Repository<QqbotBlocklist>,
) {}
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<PermissionEntity>,
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;
}
}

View File

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

71
src/qqbot/qqbot.module.ts Normal file
View File

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

52
src/qqbot/qqbot.types.ts Normal file
View File

@ -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<string, any> & {
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<string, any>;
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;
};

41
src/qqbot/qqbot.utils.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<QqbotRule>,
) {}
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<QqbotRuleBodyDto>) {
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<QqbotRule>;
}
}

View File

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

View File

@ -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<string, any>;
@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<string, any>;
@CreateDateColumn({ name: 'create_time' })
createTime: Date;
@UpdateDateColumn({ name: 'update_time' })
updateTime: Date;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

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

View File

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

View File

@ -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<QqbotSendLog>,
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>(QqbotReverseWsService, {
strict: false,
});
}
}