feat: 接入QQBot后端运行时
This commit is contained in:
parent
8139279fb6
commit
a06cf0ee3f
13
.env.example
13
.env.example
@ -22,3 +22,16 @@ ADMIN_TOKEN_SECRET=change-me
|
||||
ADMIN_COOKIE_SECURE=false
|
||||
SNOWFLAKE_WORKER_ID=1
|
||||
SNOWFLAKE_DATACENTER_ID=1
|
||||
|
||||
QQBOT_ENABLED=false
|
||||
QQBOT_EVENT_BUS=mqtt
|
||||
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
|
||||
QQBOT_REVERSE_WS_TOKEN=
|
||||
QQBOT_REQUIRE_ALLOWLIST=true
|
||||
QQBOT_API_TIMEOUT_MS=10000
|
||||
QQBOT_SEND_RATE_PER_SECOND=1
|
||||
|
||||
MQTT_URL=mqtt://127.0.0.1:1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_CLIENT_ID=kt-template-online-api-qqbot
|
||||
|
||||
@ -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",
|
||||
|
||||
248
pnpm-lock.yaml
248
pnpm-lock.yaml
@ -35,6 +35,9 @@ importers:
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
mqtt:
|
||||
specifier: ^5.15.1
|
||||
version: 5.15.1
|
||||
mysql2:
|
||||
specifier: ^3.22.3
|
||||
version: 3.22.3(@types/node@18.11.18)
|
||||
@ -56,6 +59,9 @@ importers:
|
||||
typeorm:
|
||||
specifier: ^0.3.28
|
||||
version: 0.3.28(mssql@9.3.2(@azure/core-client@1.10.1))(mysql2@3.22.3(@types/node@18.11.18))(ts-node@10.9.2(@types/node@18.11.18)(typescript@4.9.5))
|
||||
ws:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^9.5.0
|
||||
@ -81,6 +87,9 @@ importers:
|
||||
'@types/supertest':
|
||||
specifier: ^2.0.16
|
||||
version: 2.0.16
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^5.62.0
|
||||
version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
@ -384,6 +393,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -789,6 +802,9 @@ packages:
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
'@types/readable-stream@4.0.23':
|
||||
resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==}
|
||||
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
@ -810,6 +826,9 @@ packages:
|
||||
'@types/supertest@2.0.16':
|
||||
resolution: {integrity: sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
@ -935,6 +954,10 @@ packages:
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -1124,6 +1147,9 @@ packages:
|
||||
bl@5.1.0:
|
||||
resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==}
|
||||
|
||||
bl@6.1.6:
|
||||
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==}
|
||||
|
||||
block-stream2@2.1.0:
|
||||
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
|
||||
|
||||
@ -1145,6 +1171,9 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
broker-factory@3.1.14:
|
||||
resolution: {integrity: sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==}
|
||||
|
||||
browser-or-node@2.1.1:
|
||||
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
|
||||
|
||||
@ -1289,6 +1318,9 @@ packages:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
commist@3.2.0:
|
||||
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
@ -1299,6 +1331,10 @@ packages:
|
||||
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
|
||||
engines: {'0': node >= 0.8}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
consola@2.15.3:
|
||||
resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==}
|
||||
|
||||
@ -1630,6 +1666,10 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@ -1677,6 +1717,10 @@ packages:
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-unique-numbers@9.0.27:
|
||||
resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==}
|
||||
engines: {node: '>=18.2.0'}
|
||||
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
@ -1893,6 +1937,9 @@ packages:
|
||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@ -1976,6 +2023,10 @@ packages:
|
||||
resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -2307,6 +2358,9 @@ packages:
|
||||
js-md4@0.3.2:
|
||||
resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}
|
||||
|
||||
js-sdsl@4.3.0:
|
||||
resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@ -2549,6 +2603,14 @@ packages:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
mqtt-packet@9.0.2:
|
||||
resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==}
|
||||
|
||||
mqtt@5.15.1:
|
||||
resolution: {integrity: sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
@ -2642,6 +2704,9 @@ packages:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
number-allocator@1.0.14:
|
||||
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -2807,6 +2872,10 @@ packages:
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -2862,6 +2931,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
@ -3069,6 +3142,14 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
socks@2.8.9:
|
||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
source-map-support@0.5.13:
|
||||
resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==}
|
||||
|
||||
@ -3091,6 +3172,10 @@ packages:
|
||||
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
@ -3588,6 +3673,18 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
worker-factory@7.0.49:
|
||||
resolution: {integrity: sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==}
|
||||
|
||||
worker-timers-broker@8.0.16:
|
||||
resolution: {integrity: sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==}
|
||||
|
||||
worker-timers-worker@9.0.14:
|
||||
resolution: {integrity: sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==}
|
||||
|
||||
worker-timers@8.0.31:
|
||||
resolution: {integrity: sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3603,6 +3700,18 @@ packages:
|
||||
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml2js@0.5.0:
|
||||
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@ -4019,6 +4128,8 @@ snapshots:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@ -4570,6 +4681,10 @@ snapshots:
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/readable-stream@4.0.23':
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/send@0.17.6':
|
||||
@ -4600,6 +4715,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.35':
|
||||
@ -4784,6 +4903,10 @@ snapshots:
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
optional: true
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@ -4993,6 +5116,13 @@ snapshots:
|
||||
readable-stream: 3.6.2
|
||||
optional: true
|
||||
|
||||
bl@6.1.6:
|
||||
dependencies:
|
||||
'@types/readable-stream': 4.0.23
|
||||
buffer: 6.0.3
|
||||
inherits: 2.0.4
|
||||
readable-stream: 4.7.0
|
||||
|
||||
block-stream2@2.1.0:
|
||||
dependencies:
|
||||
readable-stream: 3.6.2
|
||||
@ -5044,6 +5174,13 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
broker-factory@3.1.14:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
worker-factory: 7.0.49
|
||||
|
||||
browser-or-node@2.1.1: {}
|
||||
|
||||
browserslist@4.28.2:
|
||||
@ -5180,6 +5317,8 @@ snapshots:
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
commist@3.2.0: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@ -5191,6 +5330,13 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
typedarray: 0.0.6
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
consola@2.15.3: {}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
@ -5578,6 +5724,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@4.1.0:
|
||||
@ -5674,6 +5822,11 @@ snapshots:
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-unique-numbers@9.0.27:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fast-xml-parser@4.5.6:
|
||||
@ -5932,6 +6085,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-errors@2.0.0:
|
||||
@ -6045,6 +6200,8 @@ snapshots:
|
||||
|
||||
interpret@1.4.0: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
ipaddr.js@2.4.0: {}
|
||||
@ -6580,6 +6737,8 @@ snapshots:
|
||||
js-md4@0.3.2:
|
||||
optional: true
|
||||
|
||||
js-sdsl@4.3.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.2:
|
||||
@ -6806,6 +6965,37 @@ snapshots:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
mqtt-packet@9.0.2:
|
||||
dependencies:
|
||||
bl: 6.1.6
|
||||
debug: 4.4.3
|
||||
process-nextick-args: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mqtt@5.15.1:
|
||||
dependencies:
|
||||
'@types/readable-stream': 4.0.23
|
||||
'@types/ws': 8.18.1
|
||||
commist: 3.2.0
|
||||
concat-stream: 2.0.0
|
||||
debug: 4.4.3
|
||||
help-me: 5.0.0
|
||||
lru-cache: 10.4.3
|
||||
minimist: 1.2.8
|
||||
mqtt-packet: 9.0.2
|
||||
number-allocator: 1.0.14
|
||||
readable-stream: 4.7.0
|
||||
rfdc: 1.4.1
|
||||
socks: 2.8.9
|
||||
split2: 4.2.0
|
||||
worker-timers: 8.0.31
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@ -6895,6 +7085,13 @@ snapshots:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
number-allocator@1.0.14:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
js-sdsl: 4.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@ -7054,6 +7251,8 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@ -7124,6 +7323,14 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
@ -7188,8 +7395,7 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1:
|
||||
optional: true
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@3.0.2:
|
||||
dependencies:
|
||||
@ -7369,6 +7575,13 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.2.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
source-map-support@0.5.13:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
@ -7387,6 +7600,8 @@ snapshots:
|
||||
|
||||
split-on-first@1.1.0: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
@ -7924,6 +8139,33 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
worker-factory@7.0.49:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
|
||||
worker-timers-broker@8.0.16:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
broker-factory: 3.1.14
|
||||
fast-unique-numbers: 9.0.27
|
||||
tslib: 2.8.1
|
||||
worker-timers-worker: 9.0.14
|
||||
|
||||
worker-timers-worker@9.0.14:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
worker-factory: 7.0.49
|
||||
|
||||
worker-timers@8.0.31:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
tslib: 2.8.1
|
||||
worker-timers-broker: 8.0.16
|
||||
worker-timers-worker: 9.0.14
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -7943,6 +8185,8 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
signal-exit: 3.0.7
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
xml2js@0.5.0:
|
||||
dependencies:
|
||||
sax: 1.6.0
|
||||
|
||||
189
sql/qqbot-init.sql
Normal file
189
sql/qqbot-init.sql
Normal 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;
|
||||
@ -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,
|
||||
|
||||
72
src/qqbot/account/qqbot-account.controller.ts
Normal file
72
src/qqbot/account/qqbot-account.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
44
src/qqbot/account/qqbot-account.dto.ts
Normal file
44
src/qqbot/account/qqbot-account.dto.ts
Normal 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;
|
||||
}
|
||||
83
src/qqbot/account/qqbot-account.entity.ts
Normal file
83
src/qqbot/account/qqbot-account.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
179
src/qqbot/account/qqbot-account.service.ts
Normal file
179
src/qqbot/account/qqbot-account.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
321
src/qqbot/connection/qqbot-reverse-ws.service.ts
Normal file
321
src/qqbot/connection/qqbot-reverse-ws.service.ts
Normal 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'] || ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/qqbot/dashboard/qqbot-dashboard.controller.ts
Normal file
18
src/qqbot/dashboard/qqbot-dashboard.controller.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
64
src/qqbot/dashboard/qqbot-dashboard.service.ts
Normal file
64
src/qqbot/dashboard/qqbot-dashboard.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
src/qqbot/dedupe/qqbot-dedupe.entity.ts
Normal file
37
src/qqbot/dedupe/qqbot-dedupe.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/qqbot/dedupe/qqbot-dedupe.service.ts
Normal file
32
src/qqbot/dedupe/qqbot-dedupe.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/qqbot/event/qqbot-event-normalizer.ts
Normal file
66
src/qqbot/event/qqbot-event-normalizer.ts
Normal 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();
|
||||
}
|
||||
51
src/qqbot/event/qqbot-event.service.ts
Normal file
51
src/qqbot/event/qqbot-event.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
src/qqbot/message/qqbot-conversation.entity.ts
Normal file
66
src/qqbot/message/qqbot-conversation.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/qqbot/message/qqbot-message.controller.ts
Normal file
28
src/qqbot/message/qqbot-message.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
27
src/qqbot/message/qqbot-message.dto.ts
Normal file
27
src/qqbot/message/qqbot-message.dto.ts
Normal 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;
|
||||
}
|
||||
73
src/qqbot/message/qqbot-message.entity.ts
Normal file
73
src/qqbot/message/qqbot-message.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
175
src/qqbot/message/qqbot-message.service.ts
Normal file
175
src/qqbot/message/qqbot-message.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
src/qqbot/mqtt/qqbot-bus.service.ts
Normal file
82
src/qqbot/mqtt/qqbot-bus.service.ts
Normal 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(/:\/\/([^:@]+):([^@]+)@/, '://***:***@');
|
||||
}
|
||||
}
|
||||
45
src/qqbot/permission/qqbot-allowlist.entity.ts
Normal file
45
src/qqbot/permission/qqbot-allowlist.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/qqbot/permission/qqbot-blocklist.entity.ts
Normal file
45
src/qqbot/permission/qqbot-blocklist.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
82
src/qqbot/permission/qqbot-permission.controller.ts
Normal file
82
src/qqbot/permission/qqbot-permission.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
43
src/qqbot/permission/qqbot-permission.dto.ts
Normal file
43
src/qqbot/permission/qqbot-permission.dto.ts
Normal 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;
|
||||
}
|
||||
140
src/qqbot/permission/qqbot-permission.service.ts
Normal file
140
src/qqbot/permission/qqbot-permission.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/qqbot/qqbot.constants.ts
Normal file
13
src/qqbot/qqbot.constants.ts
Normal 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
71
src/qqbot/qqbot.module.ts
Normal 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
52
src/qqbot/qqbot.types.ts
Normal 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
41
src/qqbot/qqbot.utils.ts
Normal 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;
|
||||
}
|
||||
41
src/qqbot/rule/qqbot-rule-engine.service.ts
Normal file
41
src/qqbot/rule/qqbot-rule-engine.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/qqbot/rule/qqbot-rule.controller.ts
Normal file
66
src/qqbot/rule/qqbot-rule.controller.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/qqbot/rule/qqbot-rule.dto.ts
Normal file
53
src/qqbot/rule/qqbot-rule.dto.ts
Normal 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;
|
||||
}
|
||||
65
src/qqbot/rule/qqbot-rule.entity.ts
Normal file
65
src/qqbot/rule/qqbot-rule.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
153
src/qqbot/rule/qqbot-rule.service.ts
Normal file
153
src/qqbot/rule/qqbot-rule.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
42
src/qqbot/send/qqbot-rate-limit.service.ts
Normal file
42
src/qqbot/send/qqbot-rate-limit.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/qqbot/send/qqbot-send-log.entity.ts
Normal file
62
src/qqbot/send/qqbot-send-log.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/qqbot/send/qqbot-send.controller.ts
Normal file
46
src/qqbot/send/qqbot-send.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
44
src/qqbot/send/qqbot-send.dto.ts
Normal file
44
src/qqbot/send/qqbot-send.dto.ts
Normal 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;
|
||||
}
|
||||
178
src/qqbot/send/qqbot-send.service.ts
Normal file
178
src/qqbot/send/qqbot-send.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user