初见端倪
This commit is contained in:
commit
217af5a40c
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
dist
|
||||
/db
|
||||
8
README.md
Normal file
8
README.md
Normal 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
29
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
src/db/config.ts
Normal file
35
src/db/config.ts
Normal 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
52
src/index.ts
Normal 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
17
src/qqhook/api.ts
Normal 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
63
src/qqhook/index.ts
Normal 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); }
|
||||
}
|
||||
8
src/qqhook/router/index.ts
Normal file
8
src/qqhook/router/index.ts
Normal 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;
|
||||
}
|
||||
60
src/qqhook/router/qqbot.ts
Normal file
60
src/qqhook/router/qqbot.ts
Normal 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
15
src/qqhook/utils/sign.ts
Normal 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
17
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user