From 217af5a40c9c1b11cb3337038eb8b101ec6fcabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E6=B4=9B=E4=BC=8A=E5=B0=94?= Date: Fri, 1 Nov 2024 14:14:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E8=A7=81=E7=AB=AF=E5=80=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++ README.md | 8 +++++ package.json | 29 +++++++++++++++++ qqbot.tar | Bin 0 -> 20480 bytes src/db/config.ts | 35 +++++++++++++++++++++ src/index.ts | 52 ++++++++++++++++++++++++++++++ src/qqhook/api.ts | 17 ++++++++++ src/qqhook/index.ts | 63 +++++++++++++++++++++++++++++++++++++ src/qqhook/router/index.ts | 8 +++++ src/qqhook/router/qqbot.ts | 60 +++++++++++++++++++++++++++++++++++ src/qqhook/utils/sign.ts | 15 +++++++++ tsconfig.json | 17 ++++++++++ 12 files changed, 308 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 qqbot.tar create mode 100644 src/db/config.ts create mode 100644 src/index.ts create mode 100644 src/qqhook/api.ts create mode 100644 src/qqhook/index.ts create mode 100644 src/qqhook/router/index.ts create mode 100644 src/qqhook/router/qqbot.ts create mode 100644 src/qqhook/utils/sign.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc18fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +pnpm-lock.yaml +dist +/db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3aac14 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 开发环境 + +* Nodejs `v20.18.0` +* npm `10.8.2` +* deno `1.41.3` + * (`release`, `x86_64-pc-windows-msvc`) + * v8 `12.3.219.9` + * typescript `5.3.3` diff --git a/package.json b/package.json new file mode 100644 index 0000000..86395f7 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "qqbot", + "version": "1.0.0", + "description": "Cloyir's QQBot", + "main": "index.ts", + "type": "module", + "scripts": { + "dev": "deno run -A src/index.ts", + "test": "/root/.deno/bin/deno run -A ./src/index.js", + "nodemon": "deno run -A --watch src/index.ts", + "build:linux": "deno compile --no-check -o dist/test --target x86_64-unknown-linux-gnu -A src/index.ts", + "build:win": "deno compile --no-check -o dist/test --target x86_64-pc-windows-msvc -A src/index.ts" + }, + "author": "mine123456@foxmail.com", + "license": "ISC", + "dependencies": { + "@koa/router": "^13.1.0", + "@noble/ed25519": "^2.1.0", + "axios": "^1.7.7", + "chalk": "^5.3.0", + "koa": "^2.15.3", + "koa-static": "^5.0.0", + "lowdb": "^7.0.1" + }, + "devDependencies": { + "@types/koa": "^2.15.0", + "@types/koa__router": "^12.0.4" + } +} \ No newline at end of file diff --git a/qqbot.tar b/qqbot.tar new file mode 100644 index 0000000000000000000000000000000000000000..41d7e9d89ac4a09569335c66a78bff3751ec6b5c GIT binary patch literal 20480 zcmeHPZF3vPk=AEb=|4r)M*adeNdk}ip^H~~r}0yEP+ zJ>4_?I=daKv1rXp^A+E94js^^SSl7LCnpaT)hGS8RIQW_mBuGa#ffsMJYGJ8@s(of zkSHDyJqfg3Kd?NIlxY0d`LTXjh>T;kWu_)FOG|Y($mFPLS$e+hI@DS+i)K-^Hl^S2 z>`tJ2PPW`N+spdm#TTCm0o#_%P`1;Qt7hOUz+kPzGql}iwLyic1wzU-f{Dd1=&~>c~>-TKPV7wXvdbCmw{OQ-o)(0 zc)sf_I_`>-XDs=7r~9atsmWFp1DV3FJ5{HV2YAz6@$+qexiK^m7`?&rwYtHA>(P?h zwj)dB%6N6+#GJbd@wH6MszlVX8`AMva;K+HhI^nRAzPp1(hVddeYsRI zv2Pdxj6dPH^_DEiX1Q7|{X5P2ayi~_t=cXj%e-7NC(X$atg&FV7P+}!BU%7ZOtqmJ@Z(&c@eV2J&%6 zet6?sqC)7H95kuL;`na*pR5Awf2jRZv0Q;No~TY%4vFeP6Gr&|#q~eQzga)nEwEqt z7bhyEYH*9ygFZ|>=cr|A{>BDu|5Obc}7Fo8O1#+CSPaQd|M;{fIFXr%%hfrs2 z&QJGutY9HNf_@K;@!W>=6NoyhDH~13PJl+l-wDZ)!b8Rav|8P$bxf=uVP6#I0gjwH70eY{%rQZ0 zFkf+PN1-2}9Rp3|!`gGgnDb4!3TM!t28Biv$Jm(gWYF~-J>FWe>;SAZvFx_(OW$lS zHf=9NzmCz3E5J93!T7l>z-kO!02b|)Z{$t5O_e(Q;&-{GX0j9#2v<%5*k zmd*OC62>gz0b{Ht&L}xQJ)r1##0B09LmN5UtY>o~tI%+qj-PZ|O|`e}xiw>GnUN?I z8x$}Yu69lGtjB3FFk2xx)i7?ocl9tFyrvOKUTtoDaLguTO~Owrozd@q7!C}= z!3>lK5}J_SCFWpRX#m)+*nt7e!=x&IDU|MM%by=pOFWk^cgrk~sT``i6L-|k<1bMuqm z^)9`u5S>@{dSi^eDNR#~hjCI8w7@Uu-T7Vb&W-K&-q^nMX787mdrZydyRY`IUhLnz z3QBwL|Fet4;O>(*CaTzwRwYqAAb)5xX}*Ro@Z2IQoiKsrHaXo!iNH8MMp zpmCvqedRc^kcw=K$V(;r|EjslUd~t9Fh<}{oROB$%E%?*=ka_)0cl*Js+poQmnvej}lTzDWxF7!S>v7zh?|ixa z=I2icHw1ZU^TCIk5AO7?ecZeD`<>7xxp98*6TU02i4V%)xU}HnOUE$A_@t^av@3Z} ztp+j2x{e6LxX{0Oy?5<)?~9AQ8+RYx|Mfx;bZQ0cR)Qfdv{6&vN%L&zuTUf#7uc86 zy5q>ghyWo=UA)jvs}iF*}|mwdg}Hq7dnuOG{LWDgb+7XifydF^j?k6 zWrp;u#9^fA;!KJyAsp5_E9){x0mA!Y$4zMEeJ= z!5Ow8L#?c2hgwzoH)>AifRhAZl5QhN(m?+Hf28IozaMQ5LOto}pbZHIG#RGJ)}{XM&U*1GyGw2vZ~mkC+OC38HTMdEelZ})n=%Y z17mIbrd~fsa!(0u$~yxfTJ$4qg4fB=(y`TnHf5AjNDYmEc9?f zd}>0yAl(1rI-YMXpb&vuYveNGlGnNH6kl<&e~K$Xu9H2Fxi9cNHG$w7U1r>)+qjUT zndiU~1DuLjC?BS&&TU`)OaBkQs0mccde?3tmzEQZbl^l0)r!+0-KeY1*lh__s z34ukN&xxP#`8PY3=gZShV5G5%qXO3{i{iVYia&AIY={P5V>2ZAj0qtU1&>$}SN)>p zZ2kIP?>E1VbEfzHXIp>yKqm{smFWE)?Iqq3q~_qOx_mv*uW7|{lKkgT115;_yB+C8 zl_NsUXmGITgh5>KtSoB{v~fmOKriAu4c8^i9G7MZrhsFMznY4eRMoBljM}wo7Qo@` zq|qyn*7H% z#>UK8qeWR#*?eB&_Fj`Z6LT|)#D3E2j|8PPlFnTOo1OnOlh_ z5*8vajcP2dl91^pdsq`QIe`>yvn5fLHz-A`XGNZyjM>oi8Zd%Vm`An59h|twXo~s& zYTq5eRj~c$|B98$`1l0wf0at(75M+7`+tui*nNTy%m2xTfqTS%xE(qX<^LwDMaUob zKu7i8eM<20hUoxI87Li{#qV;%faoD?6XWBuR3nC+L~N$6Wa;=Tbtg-K7-a6khzIvz zG@^CoA*GBaRcbX@XdyigK-m-$CT+3RpvY7vTX(XRWW=@Gt}tSKFpiJM!*4V6@c9pA z`s7x%$N3*GLfO;vKdAp87gXi*Um4y1d~yQV1=Ie|e=O;eiEG!P963mhxJkCx<1@_1Xg_2(`W>%IgDNn;uY6ac&Zc$ z{-?VFvz&;54<4-Ia^q4MAfC(U{yg<(&TAi;R0Gun62RXtkp+5AE}IBr z2##hzAYf$Ayf8ge1If%Fb)ynDI3@0Hs!JU!HXQdZ-z+5xnF2A4Kh%1Jn?8iU?l#xJ zuI%<(?`++@%lA$OC?5Xx`oq88AI2108{tXU72@;gn2v_27@Y(YURNX!{kTE(61TN1 zbI_r$$VNgC1V^71>J#ezipbIqQLXC#P!LwL`D3o0BmT?M;7G{z?wq>#!YICbOrQY~ zMVwATwId=VuOd9iVbJ}Vb>D`_G^IL(wqBpdcc zG5PG!o(YH0v|nrQ&=wSPuGhAL=z7efXd~q5jl-$zAic~JPjGu43}9a`JA6%;AZT6 zC=l!=Y`oOI&BV|knZf4hsvy>kg176vSUDNT$}^R zhVoImjW4xi&-LH>ym#f(%{zbV-MHTW({1L_z5ZuEPbG|E`w&Zv_5$u}NmjaXUM6d> zG01h1aXVO9sVw428Ir|!Xc+XUDciVm&e)KYP)x?1`xHZyD^VFRwUGYJk9+UGx_RT5 zo1Z=C{qgnwe|_4&{t0LI6wYuP({9%ab3^bdVpHsje*3*w`!{dVefk}lM4BMZV4c+D zgHCw$?>HUF>6BcE+JSOVGSu{@4y8If+I82tRH1$2UHpbo*FOgbV*bB!)ec};`_KQ3 zS0*MX{x6lQc>aHs|2Y6*q)-mYU%Tq5VUP85kN6MCK9&C|RwpXe@o~t%OxFNL?|*nK zuI~?6$AA1pA1)ZFC;F0)>#O7roT0E%N;#jU@N(9Qy=ddr3(K7N~Hx+DWmw~(>oZ9 zVvc`l$4L?2!E=aICX-F`6qoL0^9(haB*Y)cRU#D6KE_w4r${{=rfc{=lC*kYV?%%^ z5+%KT0&jFMDrKsZ0ri*}xM!`EXV&nZ3wG(1XQAl9{}hk{#|{&RekSUcF*#{RQwt+> zUV%Y5DWHYa(G|xPU_BQWk@eCqK914 zgB=$mp;GN&%2`AIoP`in$47d$jO{d9-6lP^b58Xz56DwGdbDtq@|b$$`4A%T@hTvO eh;vQ)ZB2om06OIx5$b4?5eG&b7;#`99Qc3n-QV^A literal 0 HcmV?d00001 diff --git a/src/db/config.ts b/src/db/config.ts new file mode 100644 index 0000000..1db958e --- /dev/null +++ b/src/db/config.ts @@ -0,0 +1,35 @@ +import { JSONFilePreset } from 'lowdb/node'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as process from 'node:process' +import chalk from 'chalk'; + +async function createConfigFile(dbDir: string, filename: string, defaultValue: string) { + const configFilePath = path.join(dbDir, filename); + if (fs.existsSync(configFilePath)) return; + await fs.promises.mkdir(dbDir, { recursive: true }); + await fs.promises.writeFile(configFilePath, defaultValue); + console.log(chalk.red('已生成配置文件, 请手动补充机器人基本信息')); + process.exit(0); +} + +type DataConfig = { + account: string, + appId: string, + token: string, + secret: string +}; + +const DefaultDataConfig: DataConfig = { + account: "", + appId: "", + token: "", + secret: "" +} + +export async function db_config_get(): Promise { + await createConfigFile('db', 'config.json', JSON.stringify(DefaultDataConfig)); + const config = await JSONFilePreset('db/config.json', DefaultDataConfig); + config.read(); + return config.data; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..57292e8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,52 @@ +import { QQHook } from "./qqhook/index.ts"; +import { db_config_get } from "./db/config.ts"; +import chalk from "chalk"; + +const config = await db_config_get(); + +type T = 'GROUP_AT_MESSAGE_CREATE' | 'MESSAGE_CREATE'; + +const app = new QQHook({ + host: '127.0.0.1', + port: 3000, + qqbot: config, + log: console.log, + path: '/webhook/qqbot' +}); + +app.on('GROUP_AT_MESSAGE_CREATE', data => { // 用户在群聊@机器人发送消息 + console.log(chalk.red('[还没打算做群聊]')); +}) + +app.on('MESSAGE_CREATE', data => { // 用户在文字子频道内发送的所有聊天消息(私域) + console.log(chalk.yellow('文字子频道:'), data.content); + app.axios.post(`/channels/${data.channel_id}/messages`, { + // content: `<@${data.author.id}> 😅`, + msg_id: data.id, + embed: { + "title": "标题", + "prompt": "消息通知", + "thumbnail": { + "url": "xxxxxx" + }, + "fields": [ + { + "name": "当前等级:黄金" + }, + { + "name": "之前等级:白银" + }, + { + "name": "😁继续努力" + } + ] + } + }) + +}) + +app.listen(() => { + app.context.log(chalk.blue(`服务开启于http://${app.host}:${app.port}${app.path}`)); +}); + + diff --git a/src/qqhook/api.ts b/src/qqhook/api.ts new file mode 100644 index 0000000..b8b6c6c --- /dev/null +++ b/src/qqhook/api.ts @@ -0,0 +1,17 @@ +import axios from "axios"; + +export async function getAppAccessToken(appId: string, clientSecret: string) { + const ans = await axios.post('https://bots.qq.com/app/getAppAccessToken', { + appId, clientSecret + }).then(res => { + if (res.status === 200) { + const { access_token, expires_in } = res.data; + return { access_token, expires_in }; + } + throw new Error('Failed to get access token'); + }); + return ans as { + access_token: string; + expires_in: string; + }; +} diff --git a/src/qqhook/index.ts b/src/qqhook/index.ts new file mode 100644 index 0000000..e7f928e --- /dev/null +++ b/src/qqhook/index.ts @@ -0,0 +1,63 @@ +import Koa from "koa"; +import { createRouter } from "./router/index.ts"; +import axios, { type AxiosInstance } from "axios"; +import { getAppAccessToken } from "./api.ts"; +import chalk from "chalk"; + +export type QQHookConfigType = { + "account": string, + "appId": string, + "token": string, + "secret": string +} + +export type QQHookConstructType = { + host?: string, + port?: number, + log?: (...data: any[]) => void, + qqbot: QQHookConfigType, + path?: string +} + +export class QQHook extends Koa { + private _config: { + host: string, + port: number, + qqbot: QQHookConfigType, + path: string; + }; + private _qqAxios: AxiosInstance = axios.create({ baseURL: 'https://api.sgroup.qq.com' }); + + public get host() { return this._config.host; } + public get port() { return this._config.port; } + public get path() { return this._config.path; } + public get axios() { return this._qqAxios } + + private async authFlushed() { + try { + const config = this._config.qqbot; + const auth = await getAppAccessToken(config.appId, config.secret); + this._qqAxios.defaults.headers.common['Authorization'] = `QQBot ${auth.access_token}`; + this.context.log(chalk.yellow('[鉴权: 成功]'), auth.expires_in); + + setTimeout(() => this.authFlushed(), Number.parseInt(auth.expires_in) * 1000 + 5000); + } catch (error) { + this.context.log(chalk.red('[鉴权: 获取access_token失败]')); + } + } + + constructor(options: QQHookConstructType) { + super(); + const { host = '127.0.0.1', port = 3000, log = console.log, path = '/' } = options; + this._config = { host, port, qqbot: options.qqbot, path }; + this.context.log = log; + + const router = createRouter(this._config.path); + this.use(router.routes()).use(router.allowedMethods()); + + this.authFlushed(); + } + + public listen(callback: any) { return super.listen(this._config.port, this._config.host, callback); } + public on(type: T, handler: (data: any) => void) { return super.on(type, handler); } +} \ No newline at end of file diff --git a/src/qqhook/router/index.ts b/src/qqhook/router/index.ts new file mode 100644 index 0000000..fc67a06 --- /dev/null +++ b/src/qqhook/router/index.ts @@ -0,0 +1,8 @@ +import Router from "@koa/router"; +import qqbotRouter from './qqbot.ts'; + +export function createRouter(path: string): Router { + const router = new Router(); + router.use(path, qqbotRouter.routes(), qqbotRouter.allowedMethods()); + return router; +} diff --git a/src/qqhook/router/qqbot.ts b/src/qqhook/router/qqbot.ts new file mode 100644 index 0000000..c3d5a35 --- /dev/null +++ b/src/qqhook/router/qqbot.ts @@ -0,0 +1,60 @@ +import Router from "@koa/router"; +import chalk from 'chalk'; +import { getSignature } from "../utils/sign.ts"; + +const router = new Router(); + +router.post('/', async (ctx, next) => { + try { + const content_type = ctx.req.headers["content-type"]; + if (content_type !== 'application/json') { + ctx.log('POST:', chalk.red('receive data without content-type application/json')); + ctx.body = '错误: content-type 不为 application/json'; + throw new Error(); + } + await new Promise((resolve, reject) => { + let data = ''; + ctx.req.addListener('data', async buf => { data += buf; }); + ctx.req.addListener('end', async () => { + try { + ctx.state.data = JSON.parse(data); + resolve(); + } catch (_) { + ctx.log('POST:', chalk.red('receive data without json format:'), data); + ctx.body = '错误: data parse error'; + reject(); + } + }) + }) + await next(); + } catch (error) { + ctx.log('POST:', chalk.red('data parse reject')); + } +}) + +router.post('/', async (ctx, next) => { + const data = ctx.state.data; + + // 回调地址验证 + if (data.op === 13) { + const { plain_token, event_ts } = data.d; + const res = { + "plain_token": plain_token, + "signature": await getSignature(ctx.config.secret, event_ts, plain_token) + } + ctx.log(chalk.blue('[收到签名校验请求]')); + ctx.body = res; + } else if (data.op === 0) { + ctx.body = { "op": 12 }; + if (!ctx.app.emit(data.t, data.d)) { + ctx.log(chalk.red('[收到未处理类型数据]'), data.t); + } + } else { + ctx.log(chalk.red('[收到未预期请求]')); + ctx.log(chalk.blue('headers:'), ctx.headers); + ctx.log(chalk.blue('data:'), data) + } + await next(); +}) + +export default router; \ No newline at end of file diff --git a/src/qqhook/utils/sign.ts b/src/qqhook/utils/sign.ts new file mode 100644 index 0000000..cc140e3 --- /dev/null +++ b/src/qqhook/utils/sign.ts @@ -0,0 +1,15 @@ +import * as ed from '@noble/ed25519'; + +// https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html +export async function getSignature(secret: string, event_ts: string, plain_token: string): Promise { + const stringToUint8Array = (str: string) => new Uint8Array(str.split('').map(item => item.charCodeAt(0))); + + secret = (secret.repeat(Math.ceil(secret.length / 32)).slice(0, 32)); + const point = ed.ExtendedPoint.fromHex(stringToUint8Array(secret)); + const privateKey = ed.utils.precompute(32, point).toRawBytes(); + + const content = stringToUint8Array(event_ts + plain_token) + const signature = await ed.signAsync(content, privateKey); + + return Array.from(signature, item => item.toString(16).padStart(2, '0')).join(''); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dd1f50f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "noImplicitAny": false + }, + "include": [ + "src/**/*.ts", + ], + "ts-node": { + "esm": true + } +} \ No newline at end of file