初见端倪

This commit is contained in:
克洛伊尔 2024-11-01 14:14:38 +08:00
commit 217af5a40c
12 changed files with 308 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
pnpm-lock.yaml
dist
/db

8
README.md Normal file
View File

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

29
package.json Normal file
View File

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

BIN
qqbot.tar Normal file

Binary file not shown.

35
src/db/config.ts Normal file
View File

@ -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<DataConfig> {
await createConfigFile('db', 'config.json', JSON.stringify(DefaultDataConfig));
const config = await JSONFilePreset<DataConfig>('db/config.json', DefaultDataConfig);
config.read();
return config.data;
}

52
src/index.ts Normal file
View File

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

17
src/qqhook/api.ts Normal file
View File

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

63
src/qqhook/index.ts Normal file
View File

@ -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<T extends string | symbol = string> 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); }
}

View File

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

View File

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

15
src/qqhook/utils/sign.ts Normal file
View File

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

17
tsconfig.json Normal file
View File

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