commit 217af5a40c9c1b11cb3337038eb8b101ec6fcabe Author: 克洛伊尔 Date: Fri Nov 1 14:14:38 2024 +0800 初见端倪 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 0000000..41d7e9d Binary files /dev/null and b/qqbot.tar differ 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